diff --git a/.claude/skills/Release/SKILL.md b/.claude/skills/Release/SKILL.md new file mode 100644 index 000000000..10c37c8b3 --- /dev/null +++ b/.claude/skills/Release/SKILL.md @@ -0,0 +1,67 @@ +--- +name: Release +description: Drives the Yubico .NET SDK release end-to-end — version gating, release branch, NativeShims ordering, CI dispatch, tagging, Windows-wizard sign+publish, GitHub release, post-release merge-back, and Slack #ask-tla announcement. USE WHEN release, drop release, ship release, cut release, publish release, release SDK, dotnet release, NuGet release, /Release, /Release resume. +--- + +# Release + +Project-local skill for shipping a Yubico .NET SDK release. The operator invokes the skill, answers gating questions, and (on Windows) plugs in the code-sign YubiKey. Every other step (branch creation, CI dispatch, artifact download, signing, publishing, tagging, GitHub release, Slack draft) is automated or surfaces an explicit decision gate. + +The skill works in two modes: +- **`/Release`** — full flow from phase 1 (pre-flight) onward +- **`/Release resume `** — picks up at phase 5 (sign+publish) using cached state from `~/Releases//.state.json`. Used when phases 1–4 ran on macOS/Linux and the operator switches to Windows for signing. + +The Windows-only constraint (`build/sign.ps1` + smart-card YubiKey + `signtool.exe`) is enforced at phase 5 — the skill detects platform and either runs the full wizard (Windows) or stops with a handoff (macOS/Linux). + +## Workflow Routing + +| Request Pattern | Route To | +|---|---| +| Drop release, ship release, cut release, publish release, /Release, /Release resume | `Workflows/DropRelease.md` | + +## Examples + +**Example 1: Full release on Windows** +``` +User: "/Release" +→ Skill loads Workflows/DropRelease.md +→ Phase 1: confirms version 1.16.1, release date, no blocking PRs +→ Phase 2: detects no Yubico.NativeShims/ changes, skips NativeShims rebuild +→ Phase 3: creates release/1.16.1 from develop, drafts whats-new.md, opens PR to main +→ Phase 4: after PR merged, dispatches build.yml with version=1.16.1, polls until green, tags 1.16.1 +→ Phase 5 (Windows): downloads artifacts to ~/Releases/1.16.1/, runs sign.ps1, publishes to NuGet.org +→ Phase 6: creates draft GitHub release with signed assets, triggers deploy-docs.yml +→ Phase 7: merges main back to develop, prints Slack #ask-tla announcement ready to copy +``` + +**Example 2: Cross-machine release (start macOS, finish Windows)** +``` +Operator (on macOS): "/Release" +→ Phases 1-4 complete (release branch, PR, merge, tag) +→ Phase 5 detects darwin → STOPS, prints handoff with build.yml run ID and instruction to run `/Release resume 1.16.1` on Windows +→ State cached to ~/Releases/1.16.1/.state.json (run IDs, version, NativeShims flag) + +Operator (on Windows): "/Release resume 1.16.1" +→ Loads cached state, skips phases 1-4 +→ Phase 5: downloads artifacts (NativeShims first if rebuilt), runs sign.ps1, publishes +→ Phases 6-7 complete normally +``` + +**Example 3: NativeShims-bearing release** +``` +User: "/Release" +→ Phase 2 detects changes in Yubico.NativeShims/ since last tag +→ AskUserQuestion confirms rebuild + NativeShims version bump +→ Dispatches build-nativeshims.yml first, polls +→ HARD GATE: NativeShims must be signed AND published to NuGet.org BEFORE build.yml dispatches +→ Phase 5 status board shows both NativeShims and main package rows +``` + +## Hard Constraints + +- **Code-signing YubiKey must be unplugged during phases 1–4**: The operator's code-signing YubiKey must NOT be connected to the machine while any build or CI step runs. Integration tests that enumerate YubiKeys can accidentally run PIV/PGP resets against any connected key. The skill gates this: Phase 1 asks the operator to confirm the YubiKey is unplugged. Phase 5 is the ONLY phase where it should be plugged in — `signtool.exe` and `nuget sign` read the PIV certificate safely but cannot coexist with stray test runs. The skill must NEVER run integration tests itself. +- **Windows-only sign step**: phase 5 refuses to run on non-Windows +- **NativeShims ordering**: when rebuilt, NativeShims signs + publishes to NuGet.org *before* main `build.yml` dispatches +- **Tag only after green CI**: `git tag` runs only after `build.yml` reports success — failed builds mean broken artifacts and a poisoned tag +- **No Versions.props edits**: version is passed as `build.yml` workflow_dispatch input; `0.0.0-dev` stays unchanged +- **Release notes never auto-committed**: skill drafts `docs/users-manual/getting-started/whats-new.md` and shows diff for approval before commit diff --git a/.claude/skills/Release/Workflows/DropRelease.md b/.claude/skills/Release/Workflows/DropRelease.md new file mode 100644 index 000000000..0ea3b5aef --- /dev/null +++ b/.claude/skills/Release/Workflows/DropRelease.md @@ -0,0 +1,356 @@ +# DropRelease Workflow + +End-to-end Yubico .NET SDK release wizard. Drives 7 phases with explicit gates. the operator answers AskUserQuestion prompts; everything else is automated. + +## State file + +Location: `~/Releases//.state.json`. Created in phase 1, updated at every phase boundary, read by `/Release resume`. + +```json +{ + "version": "1.16.1", + "previousTag": "1.16.0", + "releaseDate": "2026-04-29", + "nativeShimsRebuild": false, + "nativeShimsVersion": null, + "nativeShimsRunId": null, + "nativeShimsPublished": false, + "buildRunId": null, + "tagPushed": false, + "currentPhase": 4, + "categorizedPRs": { "features": [], "bugfixes": [], "docs": [], "deps": [], "security": [], "misc": [] } +} +``` + +**Date storage convention**: `releaseDate` is always stored as ISO `YYYY-MM-DD`. The Phase 1 prompt collects it in human form (`Month Dth, YYYY`), but the skill normalizes to ISO before writing state. Display formatting is reapplied at write time for `whats-new.md` (long form: `April 29th, 2026`) and the Slack draft (long form). Always derive the display string from the ISO field — never store both. + +**Categorization buckets**: All buckets above MUST be present in state (even empty). Phase 3 categorizes "everything else" into `misc`; Phase 7 includes `misc` in both the `whats-new.md` Miscellaneous section and the Slack draft (under a `Miscellaneous 🧰📌` heading) so PRs are never dropped. + +The state file is the single source of truth for resume. Update it before any operation that could fail. + +## Phase 1 — Pre-flight (cross-platform) + +**Prerequisites**: +- `gh auth status` — must be authenticated +- `git remote -v` — confirm `origin` points to `Yubico/Yubico.NET.SDK` + +**Steps**: +1. `git fetch --tags origin` +2. `git tag --sort=-v:refname | head -5` → show recent tags, parse latest as `previousTag` +3. `gh pr list --base develop --state open --json number,title,author --limit 20` → display, then `AskUserQuestion`: "Any of these need to merge before release?" Options: "All clear, proceed" / "Wait — I'll merge manually" / "Specific PRs blocking" +4. `AskUserQuestion`: "Confirm release version" — default option is `+1 patch` of `previousTag` (e.g., `1.16.0` → `1.16.1`); also offer `+1 minor`, `+1 major`, custom +5. `AskUserQuestion`: "Release date" — default today (in `Month Dth, YYYY` format matching whats-new.md style) +6. **Hardware test reminder** — print: "Before continuing, confirm you've tested PIV + SCP on real YubiKey hardware. The skill cannot do this for you." Gate with `AskUserQuestion`: "Hardware tests pass?" / "Skip (not recommended)" +7. **Code-signing YubiKey safety gate** — `AskUserQuestion`: "⚠️ IMPORTANT: Your code-signing YubiKey must be UNPLUGGED from this machine during phases 1–4. Integration tests that enumerate YubiKeys can run PIV/PGP resets against any connected key. Only plug it back in when Phase 5 (sign+publish) explicitly asks for it — signtool and nuget-sign read the PIV certificate safely, but no other YubiKey operation should touch the key. Is the code-signing YubiKey unplugged?" Options: "Yes, it's unplugged" / "Let me unplug it now". If the operator needs to unplug, wait for confirmation before proceeding. +8. Create `~/Releases//` and write initial `.state.json` + +## Phase 2 — NativeShims gate (cross-platform, conditional) + +**Detection**: +```bash +git diff ..origin/develop -- Yubico.NativeShims/ --stat +``` + +**If output is empty** → set `nativeShimsRebuild: false` in state, print "✓ No NativeShims changes since , skipping rebuild", continue to phase 3. + +**If output non-empty** → +1. Print the file list +2. `AskUserQuestion`: "NativeShims changed in N files. Rebuild and publish new NativeShims package?" Options: "Yes — rebuild and bump" / "No — current published NativeShims is sufficient" / "Show me the diff first" (in which case loop back after `git diff`) +3. If yes: + - `AskUserQuestion`: "NativeShims version" — fetch latest from NuGet (`gh api /repos/Yubico/Yubico.NET.SDK/contents/Yubico.NativeShims/version.txt` or query NuGet API), default +1 patch + - `gh workflow run build-nativeshims.yml --ref develop -f version=` — capture run ID + - Poll: `gh run list --workflow=build-nativeshims.yml --limit 1 --json databaseId,status,conclusion` until status=`completed`. Print poll progress every 30s + - On `failure`: STOP, print logs URL, do not proceed + - On `success`: update state with `nativeShimsRebuild: true`, `nativeShimsVersion`, `nativeShimsRunId` + - **HARD GATE**: NativeShims MUST be signed (phase 5 wizard) AND published to NuGet.org BEFORE phase 4 dispatches `build.yml`. The skill enforces this by deferring `build.yml` dispatch in phase 4 until phase 5's NativeShims half completes — see phase 4 ordering note. + +## Phase 3 — Release branch (cross-platform) + +1. `git checkout develop && git pull origin develop` +2. `git checkout -b release/` (per gitflow + project CLAUDE.md) +3. **NativeShims lockfile repin** (only if `nativeShimsRebuild: true` in state and NativeShims published as stable): + - Per project CLAUDE.md (NuGet floating version + lockfile pattern): `Yubico.Core/src/Yubico.Core.csproj` uses `Version="1.*-*"` and resolves via `Yubico.Core/src/packages.lock.json` + - `enforce-branch-policy` job in `.github/workflows/build.yml` HARD-FAILS on main if lockfile pins a `-prerelease` Yubico.NativeShims + - Repin against nuget.org-only environment (so the local internal feed doesn't shadow the just-published stable): + ```bash + dotnet restore Yubico.Core/src/Yubico.Core.csproj --force-evaluate --source https://api.nuget.org/v3/index.json + ``` + - `git diff Yubico.Core/src/packages.lock.json` — confirm one-line change to stable Yubico.NativeShims `` + - DO NOT edit `Yubico.Core.csproj` itself + - Commit: `git add Yubico.Core/src/packages.lock.json && git commit -m "build: repin NativeShims to stable"` +4. **Generate release notes draft**: + - Get last release date: `gh release view --json publishedAt -q .publishedAt` + - List merged PRs since: `gh pr list --base develop --state merged --search "merged:>=" --json number,title,labels,url --limit 100` + - Categorize by PR title prefix and labels (heuristics): + - `feat:` / `feature/` / label `enhancement` → **Features** (`features` bucket) + - `fix:` / `bugfix/` / label `bug` → **Bug Fixes** (`bugfixes` bucket) + - `docs:` / `doc:` → **Documentation** (`docs` bucket) + - `chore(deps):` / `build(deps):` / dependabot → **Dependencies / Maintenance** (`deps` bucket) + - `security:` / `ci:` / `.github/workflows/` touched → **Security / CI** (`security` bucket) + - everything else → **Miscellaneous** (`misc` bucket — never dropped) + - Cache full categorization (all 6 buckets including `misc`) into `state.categorizedPRs` for phase 7 Slack reuse +5. **Insert into `docs/users-manual/getting-started/whats-new.md`**: + - Read current file + - Insert new `### ` block under the appropriate `## 1.16.x Releases` heading (create the heading if needed) + - Match the existing format exactly (Release date, Features, Bug Fixes, Documentation, Misc, Dependencies subsections — emit Misc subsection whenever `misc` bucket is non-empty) + - Show diff via `git diff docs/users-manual/getting-started/whats-new.md` +6. `AskUserQuestion`: "Release notes look correct?" Options: "Yes, commit" / "Let me edit first" / "Regenerate from PRs" +7. On approval: `git add docs/users-manual/getting-started/whats-new.md && git commit -m "docs: release notes for "` +8. `git push -u origin release/` +9. **Build the PR-body file** for `gh pr create` — extract just the new `### ` block from `whats-new.md` (between the new heading and the next `### ` heading) into a real temp file, then pass it: + ```bash + notes_file=$(mktemp -t release-notes-.XXXXXX.md) + awk -v v="### " ' + $0 == v {flag=1; print; next} + flag && /^### / {exit} + flag {print} + ' docs/users-manual/getting-started/whats-new.md > "$notes_file" + gh pr create --base main --head release/ \ + --title "Release " \ + --body-file "$notes_file" + ``` + Capture the returned PR number into `state.releasePrNumber`. Keep the temp file path in state too so phase 6 can reuse it. +10. Print PR URL, instruct the operator to get reviewers + +## Phase 4 — Merge + CI dispatch (cross-platform) + +1. **Wait for merge** — poll `gh pr view --json state,mergedAt` every 60s until `state=MERGED`. Print poll updates. If the operator wants to abort polling and resume later, the state file already has the PR number — `/Release resume ` continues from here. +2. After merge: `git checkout main && git pull origin main` +3. **Ordering check** — if `nativeShimsRebuild: true` in state AND NativeShims hasn't been signed+published yet (no NuGet 200 on `https://www.nuget.org/packages/Yubico.NativeShims/`): + - Print: "⚠ NativeShims must publish to NuGet.org before main build dispatches" + - Jump to phase 5 NativeShims half (Windows-only); after that completes and NuGet shows live, return here +4. `gh workflow run build.yml --ref main -f version=` — capture run ID to state as `buildRunId` +5. Poll until `completed`. On failure: STOP, print logs URL. +6. On success: tag the release + - **Branch sanity check** (per memory `check-branch-before-amend.md`-adjacent caution): `git branch --show-current` must equal `main`; `git log -1 --oneline` should be the merge commit + - `git tag -a -m "Release "` + - `git push origin ` + - Update state: `tagPushed: true` + +## Phase 5 — Sign + publish (Windows wizard, or hard-stop) + +**Platform detection**: +```bash +# In bash: +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;; + *) PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]') ;; +esac +``` + +### If PLATFORM != windows + +STOP. Phase 5 can be reached via two entry paths — render the handoff text from actual state, not assumptions: + +- **Entry path A — NativeShims-only** (from Phase 4 step 3 ordering check; `state.buildRunId == null` and `state.tagPushed == false`): main `build.yml` has NOT been dispatched yet. The operator must sign+publish NativeShims first, then resume returns flow to Phase 4 step 4. +- **Entry path B — full release** (`state.buildRunId != null` and `state.tagPushed == true`): main build is green and tag is pushed; only sign+publish + GitHub release remain. + +Pseudocode for the handoff message (skill builds the strings from state): + +``` +═══ HANDOFF TO WINDOWS ═══ +Release needs sign+publish on Windows with your code-sign YubiKey. + +Current state (from ~/Releases//.state.json): +- Entry path: <"NativeShims-only" if buildRunId == null else "full release"> +- NativeShims rebuild: +- NativeShims run: +- NativeShims published: +- Main build run: +- Tag pushed: + +What's left after sign+publish: + + +On your Windows machine: +1. Plug in your code-sign YubiKey +2. cd +3. Invoke: /Release resume + +Do not proceed past this point on macOS/Linux. +═══ +``` +Exit cleanly. Do NOT mark phase 5 complete. + +### If PLATFORM == windows + +**5a. Pre-flight asserts** (each is a hard gate; on failure print fix instructions and stop): +- `gh auth status` — authenticated with `repo` + `workflow` scope +- `Get-Command signtool.exe` (PowerShell) — resolvable +- `Get-Command nuget.exe` — resolvable +- `$env:YUBICO_SIGNING_THUMBPRINT` — set; if not, AskUserQuestion to provide and persist for session +- YubiKey presence — best-effort: `Get-PnpDevice -Class SmartCard | Where-Object Status -eq 'OK'`. If empty, prompt: "No smart card detected — is YubiKey plugged in?" + +**5b. Staging**: +```powershell +$staging = "$HOME\Releases\" +New-Item -ItemType Directory -Force -Path "$staging\nativeshims","$staging\core" +``` + +**5c. Status board** — initialize and print after each step: +``` +Release — Sign & Publish + +[ ] NativeShims build.yml (run ) +[ ] NativeShims download +[ ] NativeShims signed +[ ] NativeShims published to NuGet +[ ] Main build.yml (run ) +[ ] Main download +[ ] Main signed +[ ] Main published to NuGet +``` +(Skip NativeShims rows if `nativeShimsRebuild: false`.) + +**Pre-flight for publish (one-time per session)**: `Invoke-NuGetPackagePush` resolves the API key from `-ApiKey` parameter or falls back to `$env:NUGET_API_KEY`. Before phase 5d/5e push steps, assert: +```powershell +if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { + # AskUserQuestion: paste API key (will be set in $env:NUGET_API_KEY for this session only) +} +``` +Never echo the API key. Never persist it to the state file. + +**5d. NativeShims half** (only if `nativeShimsRebuild: true`): +1. `gh run download --dir "$staging\nativeshims"` — confirms artifact zip lands +2. Identify the zip name (look for `*nativeshims*.zip`) +3. Sign: + ```powershell + . ./build/sign.ps1 + Invoke-NuGetPackageSigning ` + -Thumbprint $env:YUBICO_SIGNING_THUMBPRINT ` + -WorkingDirectory "$staging\nativeshims" ` + -NativeShimsZip "" + ``` + YubiKey PIN prompt will surface; tell the operator to enter it. +4. Verify: `Get-ChildItem "$staging\nativeshims\signed\packages\*.nupkg"` non-empty. +5. Publish (per `build/sign.ps1` — `Invoke-NuGetPackagePush` requires `-PackagePath`, optional `-ApiKey`/`$env:NUGET_API_KEY`, optional `-Source`/`-SkipDuplicate`): + ```powershell + Invoke-NuGetPackagePush ` + -PackagePath "$staging\nativeshims\signed\packages" ` + -SkipDuplicate + ``` + `-SkipDuplicate` is defensive against re-runs; the source defaults to `https://api.nuget.org/v3/index.json`. +6. Verify live: poll `https://www.nuget.org/packages/Yubico.NativeShims/` until 200 (NuGet indexing latency: 1–5 min). Update status board. Set `state.nativeShimsPublished: true`. +7. **Loop back to phase 4 step 4** to dispatch `build.yml` if not yet done. + +**5e. Main half**: +1. `gh run download --dir "$staging\core"` +2. Identify zips by listing artifacts: `gh run view --json artifacts -q '.artifacts[].name'` — typically `Nuget Packages` and `Symbols Packages` (confirm exact names per CI run; spaces in artifact names are quoted by `gh`). +3. Sign: + ```powershell + Invoke-NuGetPackageSigning ` + -Thumbprint $env:YUBICO_SIGNING_THUMBPRINT ` + -WorkingDirectory "$staging\core" ` + -NuGetPackagesZip "" ` + -SymbolsPackagesZip "" + ``` +4. Verify: `Get-ChildItem "$staging\core\signed\packages\*.nupkg","$staging\core\signed\packages\*.snupkg"` non-empty. +5. Publish: + ```powershell + Invoke-NuGetPackagePush ` + -PackagePath "$staging\core\signed\packages" ` + -SkipDuplicate + ``` +6. Verify live: poll `https://www.nuget.org/packages/Yubico.YubiKey/` AND `https://www.nuget.org/packages/Yubico.Core/` until both 200. +7. Update final status board rows. + +## Phase 6 — GitHub release (cross-platform; can run on Windows continuation or back on dev machine) + +1. **Create draft release** — extract the new `### ` section from `whats-new.md` to a real temp file (no bash process substitution; works on Windows PowerShell, macOS, and Linux). Reuse the temp file already produced in Phase 3 step 9 if its path is still in `state.notesFile` and the file exists; otherwise regenerate: + + **bash / zsh**: + ```bash + notes_file="${state_notes_file:-$(mktemp -t release-notes-.XXXXXX.md)}" + if [ ! -s "$notes_file" ]; then + awk -v v="### " ' + $0 == v {flag=1; print; next} + flag && /^### / {exit} + flag {print} + ' docs/users-manual/getting-started/whats-new.md > "$notes_file" + fi + gh release create \ + --draft \ + --title "" \ + --notes-file "$notes_file" \ + --generate-notes + ``` + + **PowerShell** (when phase 6 runs on Windows after sign+publish): + ```powershell + $notesFile = if ($state.notesFile -and (Test-Path $state.notesFile)) { $state.notesFile } else { New-TemporaryFile } + if ((Get-Item $notesFile).Length -eq 0) { + $whatsNew = Get-Content docs/users-manual/getting-started/whats-new.md + $start = ($whatsNew | Select-String -Pattern "^### $" | Select-Object -First 1).LineNumber + $end = ($whatsNew[$start..($whatsNew.Length - 1)] | Select-String -Pattern "^### " | Select-Object -First 1).LineNumber + $section = if ($end) { $whatsNew[($start - 1)..($start + $end - 2)] } else { $whatsNew[($start - 1)..($whatsNew.Length - 1)] } + $section | Set-Content $notesFile + } + gh release create --draft --title "" --notes-file $notesFile --generate-notes + ``` + + `--generate-notes` adds the auto "what's new" + full changelog appendix on top of the section pulled from `whats-new.md`. +2. **Upload signed assets**: for each signed `.nupkg` and `.snupkg` in `~/Releases//{nativeshims,core}/signed/packages/`: + ```bash + gh release upload + ``` +3. **Trigger docs deploy**: `gh workflow run deploy-docs.yml --ref main` — read workflow first to confirm input names; if it has `environment` input, set to `prod` +4. `AskUserQuestion`: "Draft release ready at . Publish now?" Options: "Publish" / "Leave as draft" / "Open in browser first" +5. If "Publish": `gh release edit --draft=false` + +## Phase 7 — Closing (cross-platform) + +1. **Merge main back to develop** (gitflow): + ```bash + git checkout develop && git pull + git merge main --no-ff -m "Merge main back into develop after release" + git push origin develop + ``` +2. **Assert** `build/Versions.props:43` is still `0.0.0-dev` — print warning if drifted (we never edit it; if drifted, something else changed it) +3. **Generate Slack #ask-tla announcement** — print as fenced code block ready to copy. Use cached `categorizedPRs` from state. Exact format: + +``` +NET SDK Release Announcement! 🎉🚀 +Release: 📅 +Distribution: 📦 +NuGet: +- https://www.nuget.org/packages/Yubico.YubiKey/ 🔑 +- https://www.nuget.org/packages/Yubico.Core/ 🧩 +GitHub: https://github.com/Yubico/Yubico.NET.SDK/releases/tag/ 🐙 +Latest release: https://github.com/Yubico/Yubico.NET.SDK/releases/latest ✨ +--- + + +- (#) + https://github.com/Yubico/Yubico.NET.SDK/pull/ +--- +Full Changelog: ... 🧾🔍 +https://github.com/Yubico/Yubico.NET.SDK/compare/... +Track the progress: https://nugettrends.com/packages?months=36&ids=Yubico.YubiKey 📈🔥 +``` + +Category emojis (Features → Security/CI match prior 1.15.1 announcement exactly; Miscellaneous added so `misc` PRs are never dropped): +- Features: ✨🎁 +- Bug Fixes: 🛠️✅ +- Documentation: 📚✍️ +- Dependencies / Maintenance: 🔧🧼 +- Security / CI: 🔒🤖 +- Miscellaneous: 🧰📌 + +4. **Print closing checklist** (manual — skill cannot automate): + - [ ] Post drafted message in Slack #ask-tla + - [ ] Post on GitHub Discussions (link to release) + - [ ] Close release in Jira +5. Mark `~/Releases//.state.json` as `currentPhase: 7, complete: true` + +## Failure modes & recovery + +- **CI build fails** → STOP, do not tag, do not proceed. Re-dispatch after fix. +- **Sign fails** → leave artifacts in staging; do not delete. Inspect, retry. State preserved. +- **NuGet publish 409 (already exists)** → version conflict; abort entire release, never overwrite published packages. +- **Tag push fails** (e.g., already exists) → STOP, investigate. Never force-push tags. +- **Resume on different machine** → `/Release resume ` reads `~/Releases//.state.json` and skips completed phases. Phase boundaries are the resume points. diff --git a/.github/workflows/build-nativeshims.yml b/.github/workflows/build-nativeshims.yml index 18e20ce76..9cc233bbb 100644 --- a/.github/workflows/build-nativeshims.yml +++ b/.github/workflows/build-nativeshims.yml @@ -38,7 +38,7 @@ jobs: runs-on: windows-2022 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -78,25 +78,39 @@ jobs: if %FAILED%==1 exit /b 1 echo All Windows builds verified: no VC++ Redistributable required exit /b 0 - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Verify export tables match canonical symbol list + shell: pwsh + run: | + # Set up VC++ environment so dumpbin is on PATH for arm64 inspection + & "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 + $script = "$PWD\Yubico.NativeShims\tests\check_exports.ps1" + $failed = $false + foreach ($arch in @('win-x64', 'win-x86', 'win-arm64')) { + Write-Host "=== Checking $arch\Yubico.NativeShims.dll ===" + & $script "$PWD\Yubico.NativeShims\$arch\Yubico.NativeShims.dll" + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + Write-Host "All Windows export tables match canonical symbol list." + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: win-x64 path: Yubico.NativeShims/win-x64/** - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: win-x86 path: Yubico.NativeShims/win-x86/** - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: win-arm64 path: Yubico.NativeShims/win-arm64/** - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nuspec path: | Yubico.NativeShims/*.nuspec Yubico.NativeShims/readme.md - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: msbuild path: Yubico.NativeShims/msbuild/* @@ -106,7 +120,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -253,7 +267,11 @@ jobs: readelf -V *.so | grep GLIBC_2 | sort -u echo "✅ Binary compatible with Debian 10 (glibc 2.28)" ' - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Verify export table matches canonical symbol list + working-directory: Yubico.NativeShims + run: | + bash tests/check_exports.sh linux-x64/libYubico.NativeShims.so + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux-x64 path: Yubico.NativeShims/linux-x64/*.so @@ -263,7 +281,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -414,7 +432,12 @@ jobs: readelf -V *.so | grep GLIBC_2 | sort -u echo "✅ ARM64 binary compatible with Debian 10 (glibc 2.28)" ' - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Verify export table matches canonical symbol list + working-directory: Yubico.NativeShims + run: | + # nm reads ELF metadata regardless of target arch — works on x86_64 host inspecting aarch64 .so + bash tests/check_exports.sh linux-arm64/libYubico.NativeShims.so + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux-arm64 path: Yubico.NativeShims/linux-arm64/*.so @@ -424,7 +447,7 @@ jobs: runs-on: macos-14 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -440,11 +463,19 @@ jobs: else sh ./build-macOS.sh fi - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Verify export tables match canonical symbol list + working-directory: Yubico.NativeShims + run: | + set -e + for arch in osx-x64 osx-arm64; do + echo "=== Checking $arch/libYubico.NativeShims.dylib ===" + bash tests/check_exports.sh "$arch/libYubico.NativeShims.dylib" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: osx-x64 path: Yubico.NativeShims/osx-x64/** - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: osx-arm64 path: Yubico.NativeShims/osx-arm64/** @@ -463,7 +494,7 @@ jobs: GITHUB_REPO_URL: https://github.com/${{ github.repository }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -483,7 +514,7 @@ jobs: - run: nuget pack Yubico.NativeShims.nuspec - name: Upload Nuget Package - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: NuGet Package NativeShims path: Yubico.NativeShims.*.nupkg @@ -507,7 +538,7 @@ jobs: if: ${{ github.event.inputs.push-to-dev == 'true' }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.github/workflows/build-pull-requests.yml b/.github/workflows/build-pull-requests.yml index bded0ef4d..2ee47c749 100644 --- a/.github/workflows/build-pull-requests.yml +++ b/.github/workflows/build-pull-requests.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Save build artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Nuget Packages Release path: | @@ -82,7 +82,7 @@ jobs: Yubico.YubiKey/src/bin/Release/*.nupkg - name: Save build artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Assemblies Release path: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57b2702db..aebed6aef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,8 +52,41 @@ permissions: contents: read jobs: + enforce-branch-policy: + name: Enforce branch policy + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # main branch is release-only — no prerelease Yubico packages allowed in the lockfile. + # Re-pin via `dotnet restore Yubico.Core/src/Yubico.Core.csproj --force-evaluate` against + # an "nuget.org-only" environment before merging develop -> main. + - name: Disallow prerelease Yubico.NativeShims on main + if: github.ref == 'refs/heads/main' + shell: bash + run: | + LOCK=Yubico.Core/src/packages.lock.json + # The package name and its "resolved" field live on separate lines, so use -A3 to + # bring the resolved line into the match window before checking for -prerelease. + if grep -A3 '"Yubico\.NativeShims"' "$LOCK" | grep -E '"resolved":.*-prerelease' >/dev/null; then + echo "::error file=$LOCK::main builds disallow prerelease Yubico.NativeShims. Re-pin the lockfile to a stable version (dotnet restore --force-evaluate against nuget.org) before merging." + grep -A3 '"Yubico\.NativeShims"' "$LOCK" | head -20 + exit 1 + fi + echo "Lockfile policy OK: no prerelease Yubico.NativeShims pinned." + run-tests: name: Run tests + needs: enforce-branch-policy # Requires write permissions to publish test results permissions: checks: write # Required to create check runs for test results @@ -82,20 +115,33 @@ jobs: assemblies-id: ${{ steps.assemblies-upload.outputs.artifact-id }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + + # Non-main builds (develop, workflow_dispatch, schedule) get the internal Yubico GitHub Packages + # feed in addition to nuget.org so prerelease Yubico.NativeShims can resolve. + - name: Setup .NET (non-main, with internal Yubico feed) + if: github.ref != 'refs/heads/main' + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: global-json-file: "./global.json" source-url: https://nuget.pkg.github.com/Yubico/index.json env: NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # main builds restore from nuget.org only — no internal Yubico feed, so any prerelease + # Yubico.NativeShims pinned in the lockfile would fail restore even without the policy guard. + - name: Setup .NET (main, nuget.org only) + if: github.ref == 'refs/heads/main' + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: "./global.json" + - name: Set build version if: ${{ github.event.inputs.version }} run: | @@ -119,7 +165,7 @@ jobs: # Upload documentation log - name: "Save build artifacts: Docs log" id: docs-log-upload - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Documentation log path: docfx.log @@ -128,7 +174,7 @@ jobs: # Upload documentation - name: "Save build artifacts: Docs" id: docs-upload - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Documentation path: docs/_site/ @@ -137,7 +183,7 @@ jobs: # Upload NuGet packages - name: "Save build artifacts: Nuget Packages" id: nuget-upload - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Nuget Packages path: | @@ -148,7 +194,7 @@ jobs: # Upload symbols - name: "Save build artifacts: Symbols Packages" id: symbols-upload - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Symbols Packages path: | @@ -159,7 +205,7 @@ jobs: # Upload assemblies - name: "Save build artifacts: Assemblies" id: assemblies-upload - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Assemblies path: | @@ -200,7 +246,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -208,11 +254,8 @@ jobs: with: name: Nuget Packages - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - source-url: https://nuget.pkg.github.com/Yubico/index.json - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | + dotnet nuget add source --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/Yubico/index.json" $core = (Get-ChildItem -Recurse Yubico.Core/*.nupkg)[0].FullName $yubikey = (Get-ChildItem -Recurse Yubico.YubiKey/*.nupkg)[0].FullName dotnet nuget push $core --source "github" --api-key ${{ secrets.GITHUB_TOKEN }} @@ -223,11 +266,11 @@ jobs: build-summary: name: Build summary runs-on: ubuntu-latest - needs: [run-tests, build-artifacts, publish-internal, upload-docs] + needs: [enforce-branch-policy, run-tests, build-artifacts, publish-internal, upload-docs] if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 67b2f90db..84b5d93ac 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -30,7 +30,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -42,7 +42,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@0ee1beea589a67d33340072691a5d42abec7ae6b # v1.0.78 + uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index baa08c224..490b24fca 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -16,6 +16,8 @@ name: "Run CodeQL" on: + schedule: + - cron: '0 6 * * 1' push: branches: - main @@ -55,7 +57,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -74,7 +76,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: # Override automatic language detection to only analyze C# # C/C++ code in Yubico.NativeShims is built separately (requires CMake/vcpkg) @@ -87,4 +89,4 @@ jobs: NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5cdc66f9b..bd2d544ba 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 91de9ab27..bd1451db0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -46,7 +46,7 @@ jobs: - name: Generate GitHub App token id: generate_token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: 800408 # Yubico Docs owner: Yubico @@ -88,7 +88,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -105,7 +105,7 @@ jobs: - name: Generate GitHub App token id: generate_token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: 260767 # Yubico Commit Status Reader owner: Yubico diff --git a/.github/workflows/perf-regression.yml b/.github/workflows/perf-regression.yml new file mode 100644 index 000000000..e636e6c95 --- /dev/null +++ b/.github/workflows/perf-regression.yml @@ -0,0 +1,176 @@ +# Copyright 2026 Yubico AB +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Performance Regression + +on: + pull_request: + branches: + - main + - develop + paths: + - 'Yubico.Core/perf/**' + - 'Yubico.Core/src/Yubico/Core/Devices/SmartCard/**' + - '.github/workflows/perf-regression.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + benchmark: + name: Run Performance Benchmarks + runs-on: windows-latest + permissions: + contents: read + packages: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: "./global.json" + + - name: Add local NuGet repository + run: dotnet nuget add source --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/Yubico/index.json" + + - name: Build performance solution + run: dotnet build Yubico.NET.SDK.Performance.sln --configuration Release --nologo --verbosity minimal + + - name: Run benchmarks + run: | + dotnet run --configuration Release --no-build --project Yubico.Core/perf/Yubico.Core.Performance.csproj -- --filter '*SmartCardListenerInvalidHandleBenchmark*' --exporters fullJson + continue-on-error: true + id: benchmark-run + + - name: Parse benchmark results and verify thresholds + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + # Find the most recent BenchmarkDotNet results JSON + $resultsFile = Get-ChildItem -Path "BenchmarkDotNet.Artifacts/results" -Filter "*-report-full.json" -Recurse | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $resultsFile) { + Write-Error "No BenchmarkDotNet JSON results file found in BenchmarkDotNet.Artifacts/results/" + exit 1 + } + + Write-Host "Parsing results from: $($resultsFile.FullName)" + $results = Get-Content $resultsFile.FullName | ConvertFrom-Json + + # Extract the two benchmark results + $benchmarks = $results.Benchmarks + + $legacyBenchmark = $benchmarks | Where-Object { $_.FullName -match "LegacyListener_InvocationsInOneSecond" } + $fixedBenchmark = $benchmarks | Where-Object { $_.FullName -match "FixedListener_InvocationsInOneSecond" } + + if (-not $legacyBenchmark -or -not $fixedBenchmark) { + Write-Error "Could not find both benchmark results in JSON output" + Write-Host "Available benchmarks:" + $benchmarks | ForEach-Object { Write-Host " - $($_.FullName)" } + exit 1 + } + + # The benchmark returns invocation counts as the measured value + # Extract from the Statistics.Mean field (which is in nanoseconds for BDN, but our benchmark returns the count directly) + # We need to look at the actual return values from the benchmark methods + + # BDN stores the actual return value in the Statistics, but we need to parse from the full results + # The invocation counts are printed to console in GlobalCleanup, but also available via Statistics + + # For this benchmark, the Mean time is ~1 second (the observation window), but the critical metric + # is the invocation count which we print to console. We'll parse the console output instead. + + # Actually, let's get the values from the Data column which contains the raw measurements + Write-Host "Legacy benchmark: $($legacyBenchmark.FullName)" + Write-Host "Fixed benchmark: $($fixedBenchmark.FullName)" + + # The actual invocation counts should be in the console output, but for robustness, + # we can also examine the BDN output files. Let's check for the stdout file. + + $stdoutFile = Get-ChildItem -Path "BenchmarkDotNet.Artifacts/results" -Filter "*.log" -Recurse | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $stdoutFile) { + Write-Warning "No log file found, attempting to extract from JSON statistics" + # Fallback: use the baseline ratio if available + $fixedResults = $results.Benchmarks | Where-Object { $_.FullName -match "FixedListener" } + + if ($fixedResults -and $fixedResults.Statistics) { + Write-Host "Fixed benchmark statistics available" + Write-Host ($fixedResults | ConvertTo-Json -Depth 5) + } + } else { + Write-Host "Parsing console output from: $($stdoutFile.FullName)" + $consoleOutput = Get-Content $stdoutFile.FullName -Raw + + # Extract the [FINAL] lines from GlobalCleanup + if ($consoleOutput -match '\[FINAL\] Legacy invocations:\s*(\d+)') { + $legacyInvocations = [long]$matches[1] + Write-Host "Legacy invocations: $legacyInvocations" + } else { + Write-Error "Could not parse legacy invocation count from console output" + exit 1 + } + + if ($consoleOutput -match '\[FINAL\] Fixed\s+invocations:\s*(\d+)') { + $fixedInvocations = [long]$matches[1] + Write-Host "Fixed invocations: $fixedInvocations" + } else { + Write-Error "Could not parse fixed invocation count from console output" + exit 1 + } + + # Calculate the ratio + if ($fixedInvocations -eq 0) { + Write-Host "✅ PASS: Fixed implementation has zero invocations (infinite ratio)" + $ratio = [double]::PositiveInfinity + } else { + $ratio = [double]$legacyInvocations / [double]$fixedInvocations + Write-Host "Ratio (Legacy/Fixed): $ratio" + } + + # Acceptance criterion: ratio must be >= 1000 + $THRESHOLD = 1000 + + if ($ratio -ge $THRESHOLD) { + Write-Host "✅ PASS: Ratio $ratio exceeds threshold of $THRESHOLD" + Write-Host "The fix successfully reduces syscall invocations by ${ratio}x" + } else { + Write-Error "❌ FAIL: Ratio $ratio is below threshold of $THRESHOLD" + Write-Host "Expected: Legacy/Fixed >= $THRESHOLD" + Write-Host "Actual: $legacyInvocations / $fixedInvocations = $ratio" + exit 1 + } + } + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BenchmarkDotNet-Results + path: BenchmarkDotNet.Artifacts/ + if-no-files-found: warn diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3ced6c2dc..a43446a7e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -70,7 +70,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif @@ -79,6 +79,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: results.sarif diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index af59408ae..0730be639 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: run: dotnet test Yubico.Core/tests/Yubico.Core.UnitTests.csproj --filter "FullyQualifiedName!~DisposalTests" --logger trx --settings coverlet.runsettings.xml --collect:"XPlat Code Coverage" - name: Upload Test Result Files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: TestResults-macOS path: '**/TestResults/*' diff --git a/.github/workflows/test-ubuntu.yml b/.github/workflows/test-ubuntu.yml index 3d1c40bf8..d71774eb4 100644 --- a/.github/workflows/test-ubuntu.yml +++ b/.github/workflows/test-ubuntu.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -57,7 +57,7 @@ jobs: run: dotnet test Yubico.Core/tests/Yubico.Core.UnitTests.csproj --logger trx --settings coverlet.runsettings.xml --collect:"XPlat Code Coverage" - name: Upload Test Result Files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: TestResults-Ubuntu path: '**/TestResults/*' diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 0c93908d7..91cee5fac 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -52,7 +52,7 @@ jobs: run: dotnet test Yubico.Core/tests/Yubico.Core.UnitTests.csproj --logger trx --settings coverlet.runsettings.xml --collect:"XPlat Code Coverage" - name: Upload Test Result Files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: TestResults-Windows path: '**/TestResults/*' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24808f321..e983ee585 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,13 +81,13 @@ jobs: if: inputs.build-coverage-report == true steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Combine Coverage Reports # This is because one report is produced per project, and we want one result for all of them. - uses: danielpalme/ReportGenerator-GitHub-Action@cf6fe1b38ed5becc89ffe056c1f240825993be5b # 5.5.4 + uses: danielpalme/ReportGenerator-GitHub-Action@a003c8fb9ac008fd0fffd5faa4f7d3ecb52e0675 # 5.5.7 with: reports: "**/*.cobertura.xml" # REQUIRED # The coverage reports that should be parsed (separated by semicolon). Globbing is supported. targetdir: "${{ github.workspace }}" # REQUIRED # The directory where the generated report should be saved. @@ -112,7 +112,7 @@ jobs: thresholds: "40 60" - name: Upload Code Coverage Report - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CoverageResults path: code-coverage-results.md @@ -129,7 +129,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit @@ -139,7 +139,7 @@ jobs: name: CoverageResults - name: Add PR Comment - uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: recreate: true path: code-coverage-results.md @@ -157,7 +157,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index 6bf2e1b94..47368b439 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -45,7 +45,7 @@ jobs: steps: # Checkout the local repository as we need the Dockerfile and other things even for this step. - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.github/workflows/verify-code-style.yml b/.github/workflows/verify-code-style.yml index e411bb84c..d6e0eda15 100644 --- a/.github/workflows/verify-code-style.yml +++ b/.github/workflows/verify-code-style.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 with: egress-policy: audit diff --git a/.gitignore b/.gitignore index e6cbea519..1b6e81003 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,10 @@ mono_crash.* [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ +# Exception: project-local Claude skill at .claude/skills/Release/ collides with +# the [Rr]elease/ pattern above (intended for .NET bin/Release output). Re-include it. +!.claude/skills/Release/ +!.claude/skills/Release/** x64/ x86/ [Aa][Rr][Mm]/ @@ -554,4 +558,8 @@ cython_debug/ # Coverage / Test Results coveragereport/ -TestResults/ \ No newline at end of file +TestResults/ + +.claude/settings.local.json +.claude/scheduled_tasks.lock +Yubico.Core/fuzz/ diff --git a/CLAUDE.md b/CLAUDE.md index fa28509af..eaccc3ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,14 @@ Integration tests use standardized YubiKey devices enumerated in `StandardTestDe - System.Formats.Cbor - Yubico.Core (project reference) +### Yubico.NativeShims version management +- The `` in `Yubico.Core/src/Yubico.Core.csproj` uses a floating range (`Version="1.*-*"`), **not** a hard-pinned version. Don't hand-bump it. +- The exact resolved version is captured in `Yubico.Core/src/packages.lock.json` (committed). Restore honors the lock; only `dotnet restore Yubico.Core/src/Yubico.Core.csproj --force-evaluate` re-floats. +- To bump to a new prerelease/stable: run `--force-evaluate`, review the one-line lockfile diff, commit only `packages.lock.json`. The csproj does not change. +- `` is intentionally absent — lockfiles aren't OS-portable when implicit packages differ across the CI matrix (NU1004 on Windows when generated on macOS/Linux). +- The `enforce-branch-policy` job in `.github/workflows/build.yml` greps the lockfile and **hard-fails on `main`** if a `-prerelease` Yubico.NativeShims is pinned. Before merging develop → main, re-pin to a stable version with `--force-evaluate` against an nuget.org-only environment. +- `build.yml`'s `build-artifacts` job adds the internal Yubico GitHub Packages feed only on non-main branches; main builds restore from nuget.org only. Don't unify those steps. + ## Important Notes - Strong-name signed assemblies using `Yubico.NET.SDK.snk` diff --git a/Dockerfile b/Dockerfile index 3521ecc92..d02dc7ef9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM nginx:alpine@sha256:e7257f1ef28ba17cf7c248cb8ccf6f0c6e0228ab9c315c152f9c203cd34cf6d1 +FROM nginx:alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de ARG UID=1000 ARG GID=1000 diff --git a/Plans/perf/scard-listener-invalid-handle.md b/Plans/perf/scard-listener-invalid-handle.md new file mode 100644 index 000000000..9cb21a7c6 --- /dev/null +++ b/Plans/perf/scard-listener-invalid-handle.md @@ -0,0 +1,72 @@ +# Performance Benchmark: PR #445 SCard Busy-Loop Fix + +## Metadata + +- **Date:** 2026-04-21 +- **Branch:** `feature/scard-listener-followups` +- **HEAD SHA:** df6fcbd5 (Step 4 of stacked follow-ups) +- **Base:** PR #445 at 40933696 (`origin/dennisdyallo/fix-rds-scard-invalid-handle`) + +## Benchmark Description + +This benchmark proves that PR #445's recovery wiring for `SCARD_E_INVALID_HANDLE` eliminates the busy-loop bug observed in production YubiPCS 1.9.1.2 logs, where the listener was making ~3,700 `SCardGetStatusChange` calls per second under persistent context-invalidation failures. + +The benchmark compares: + +1. **Legacy (develop pre-#445):** Simplified snapshot preserving the busy-loop characteristic — no recovery path for `SCARD_E_INVALID_HANDLE`, so `GetStatusChange` is called in a tight loop. +2. **Fixed (#445 + follow-ups):** Current listener with exponential backoff recovery (Steps 1-4 already applied). + +Both listeners are fed a mock `ISCardInterop` that returns `SCARD_E_INVALID_HANDLE` on every call after the initial probe. The observation window is 1 second. + +## BenchmarkDotNet Report + +``` + +BenchmarkDotNet v0.14.0, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] +Apple M1, 1 CPU, 8 logical and 8 physical cores +.NET SDK 9.0.308 + [Host] : .NET 8.0.13 (8.0.1325.6609), Arm64 RyuJIT AdvSIMD + Job-EFTRWD : .NET 8.0.13 (8.0.1325.6609), Arm64 RyuJIT AdvSIMD + +Runtime=.NET 8.0 InvocationCount=1 IterationCount=5 +RunStrategy=Monitoring UnrollFactor=1 WarmupCount=1 + +``` +| Method | Mean | Error | StdDev | Ratio | +|--------------------------------- |--------:|---------:|---------:|------:| +| 'develop (pre-#445) — busy spin' | 1.003 s | 0.0063 s | 0.0016 s | 1.00 | +| '#445 fix — bounded recovery' | 1.002 s | 0.0051 s | 0.0013 s | 1.00 | + +## Invocation Counts (Observed via Console Output) + +- **Legacy (develop):** 134,789,691 invocations in 1 second +- **Fixed (#445):** 1 invocation in 1 second + +## Analysis + +The Mean times are both ~1.0 second because that's the `Thread.Sleep` observation window, which dominates execution time. The **critical metric is the invocation count**: + +- **Legacy:** ~135 million calls/sec (busy spin with no delay) +- **Fixed:** 1 call/sec (backoff working correctly) +- **Ratio:** ~135,000,000× reduction + +## Verdict + +**PASS** — Ratio exceeds the >= 1,000× acceptance criterion by ~135,000×. + +The #445 fix (with Steps 1-4 follow-ups applied) successfully eliminates the busy-loop. Under persistent `SCARD_E_INVALID_HANDLE` failures, the listener now backs off with exponential delays instead of spinning the CPU at ~135M iterations/sec. + +## Notes + +- The legacy listener is a simplified 120-line snapshot (`LegacyDesktopSmartCardDeviceListener`) that preserves the essential busy-loop characteristic, not a full port of the 513-line develop file. This approach was chosen per the brief's fallback guidance when the verbatim port proved complex. +- The benchmark resides in a separate `Yubico.NET.SDK.Performance.sln` to keep perf projects out of the default build. +- Both assemblies are strong-name signed with `Yubico.NET.SDK.snk`. + +## Reproducibility + +```bash +dotnet build Yubico.NET.SDK.Performance.sln --configuration Release +dotnet run --configuration Release --project Yubico.Core/perf/Yubico.Core.Performance.csproj -- --filter '*SmartCardListenerInvalidHandleBenchmark*' +``` + +Expect the benchmark to complete in ~12 seconds (warmup + 5 iterations × 2 benchmarks). diff --git a/Yubico.Core/fuzz/Program.cs b/Yubico.Core/fuzz/Program.cs new file mode 100644 index 000000000..2ae8a61e2 --- /dev/null +++ b/Yubico.Core/fuzz/Program.cs @@ -0,0 +1,181 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using SharpFuzz; +using Yubico.Core.Buffers; +using Yubico.Core.Iso7816; +using Yubico.Core.Tlv; + +namespace Yubico.Core.Fuzz; + +public delegate void FuzzTarget(ReadOnlySpan data); + +public static class Program +{ + private const int MaxStackAllocSize = 1024; + + private static readonly Dictionary Targets = new() + { + ["tlv-reader"] = FuzzTlvReader, + ["tlv-object"] = FuzzTlvObject, + ["tlv-decode-list"] = FuzzTlvDecodeList, + ["base16"] = FuzzBase16, + ["base32"] = FuzzBase32, + ["modhex"] = FuzzModHex, + ["response-apdu"] = FuzzResponseApdu, + }; + + public static void Main(string[] args) + { + if (args.Length == 0 || !Targets.ContainsKey(args[0])) + { + Console.Error.WriteLine($"Usage: Yubico.Core.Fuzz "); + Console.Error.WriteLine($"Available targets: {string.Join(", ", Targets.Keys)}"); + return; + } + + string target = args[0]; + FuzzTarget fuzzAction = Targets[target]; + + Fuzzer.LibFuzzer.Run(span => + { + fuzzAction(span); + }); + } + + private static void FuzzTlvReader(ReadOnlySpan data) + { + try + { + var reader = new TlvReader(data.ToArray()); + while (reader.HasData) + { + int tag = reader.PeekTag(); + _ = reader.ReadValue(tag); + } + } + catch (TlvException) { } + } + + private static void FuzzTlvObject(ReadOnlySpan data) + { + try + { + using var tlvObject = TlvObject.Parse(data); + } + catch (TlvException) { } + catch (ArgumentException) { } + } + + private static void FuzzTlvDecodeList(ReadOnlySpan data) + { + try + { + _ = TlvObjects.DecodeList(data); + } + catch (TlvException) { } + catch (ArgumentException) { } + } + + private static void FuzzBase16(ReadOnlySpan data) + { + try + { + int len = data.Length; + if (len == 0) + { + return; + } + + Span chars = len <= MaxStackAllocSize ? stackalloc char[len] : new char[len]; + for (int i = 0; i < len; i++) + { + chars[i] = (char)data[i]; + } + + Span output = len <= MaxStackAllocSize ? stackalloc byte[len] : new byte[len]; + Base16.DecodeText(chars, output); + } + catch (ArgumentException) { } + catch (FormatException) { } + } + + private static void FuzzBase32(ReadOnlySpan data) + { + try + { + int len = data.Length; + if (len == 0) + { + return; + } + + Span chars = len <= MaxStackAllocSize ? stackalloc char[len] : new char[len]; + for (int i = 0; i < len; i++) + { + chars[i] = (char)data[i]; + } + + int decodedSize = Base32.GetDecodedSize(chars); + var decoder = new Base32(); + Span output = decodedSize <= MaxStackAllocSize ? stackalloc byte[decodedSize] : new byte[decodedSize]; + decoder.Decode(chars, output); + } + catch (ArgumentException) { } + catch (FormatException) { } + } + + private static void FuzzModHex(ReadOnlySpan data) + { + try + { + int len = data.Length; + if (len == 0) + { + return; + } + + Span chars = len <= MaxStackAllocSize ? stackalloc char[len] : new char[len]; + for (int i = 0; i < len; i++) + { + chars[i] = (char)data[i]; + } + + Span output = len <= MaxStackAllocSize ? stackalloc byte[len] : new byte[len]; + ModHex.DecodeText(chars, output); + } + catch (ArgumentException) { } + catch (FormatException) { } + } + + private static void FuzzResponseApdu(ReadOnlySpan data) + { + try + { + if (data.Length < 2) + { + return; + } + + var apdu = new ResponseApdu(data.ToArray()); + _ = apdu.SW; + _ = apdu.SW1; + _ = apdu.SW2; + _ = apdu.Data; + } + catch (ArgumentException) { } + } +} diff --git a/Yubico.Core/fuzz/Yubico.Core.Fuzz.csproj b/Yubico.Core/fuzz/Yubico.Core.Fuzz.csproj new file mode 100644 index 000000000..50251a503 --- /dev/null +++ b/Yubico.Core/fuzz/Yubico.Core.Fuzz.csproj @@ -0,0 +1,36 @@ + + + + + + Exe + Yubico.Core.Fuzz + Yubico.Core.Fuzz + net8.0 + + true + ..\..\Yubico.NET.SDK.snk + false + + + + + + + + + + + diff --git a/Yubico.Core/perf/Benchmarks/SmartCardListenerInvalidHandleBenchmark.cs b/Yubico.Core/perf/Benchmarks/SmartCardListenerInvalidHandleBenchmark.cs new file mode 100644 index 000000000..1728060f3 --- /dev/null +++ b/Yubico.Core/perf/Benchmarks/SmartCardListenerInvalidHandleBenchmark.cs @@ -0,0 +1,77 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Jobs; +using Yubico.Core.Devices.SmartCard; +using Yubico.Core.Performance.Legacy; +using Yubico.Core.Performance.Mocks; + +namespace Yubico.Core.Performance.Benchmarks +{ + /// + /// Benchmark proving PR #445's recovery wiring fixes the SCARD_E_INVALID_HANDLE busy-loop. + /// + /// Production logs from YubiPCS 1.9.1.2 show ~3,700 SCardGetStatusChange invocations/sec + /// under persistent SCARD_E_INVALID_HANDLE failures (RDS session disconnect scenario). + /// + /// Expected ratio: Legacy/Fixed >= 1000x, because the legacy listener spins in a tight loop + /// while the #445 fix backs off with 1-second delays. + /// + [SimpleJob(RunStrategy.Monitoring, RuntimeMoniker.Net80, + warmupCount: 1, iterationCount: 5, invocationCount: 1)] + public class SmartCardListenerInvalidHandleBenchmark + { + private static readonly TimeSpan ObservationWindow = TimeSpan.FromSeconds(1); + + private AlwaysInvalidHandleScardInterop _legacyMock = null!; + private AlwaysInvalidHandleScardInterop _fixedMock = null!; + + [IterationSetup(Target = nameof(LegacyListener_InvocationsInOneSecond))] + public void SetupLegacy() => _legacyMock = new AlwaysInvalidHandleScardInterop(); + + [IterationSetup(Target = nameof(FixedListener_InvocationsInOneSecond))] + public void SetupFixed() => _fixedMock = new AlwaysInvalidHandleScardInterop(); + + [Benchmark(Baseline = true, Description = "develop (pre-#445) — busy spin")] + public int LegacyListener_InvocationsInOneSecond() + { + using var listener = new LegacyDesktopSmartCardDeviceListener(_legacyMock); + Thread.Sleep(ObservationWindow); + return _legacyMock.Invocations; + } + + [Benchmark(Description = "#445 fix — bounded recovery")] + public int FixedListener_InvocationsInOneSecond() + { + using var listener = new DesktopSmartCardDeviceListener(_fixedMock); + Thread.Sleep(ObservationWindow); + return _fixedMock.Invocations; + } + + [GlobalCleanup] + public void Report() + { + // FORMAT CONTRACT — parsed by .github/workflows/perf-regression.yml (lines 132, 140). + // CI extracts counts via regex: \[FINAL\] Legacy invocations:\s*(\d+) and + // \[FINAL\] Fixed\s+invocations:\s*(\d+). Do not change the format without + // updating the workflow. + Console.WriteLine($"[FINAL] Legacy invocations: {_legacyMock?.Invocations ?? -1}"); + Console.WriteLine($"[FINAL] Fixed invocations: {_fixedMock?.Invocations ?? -1}"); + } + } +} diff --git a/Yubico.Core/perf/Listeners/LegacyDesktopSmartCardDeviceListener.cs b/Yubico.Core/perf/Listeners/LegacyDesktopSmartCardDeviceListener.cs new file mode 100644 index 000000000..2381df173 --- /dev/null +++ b/Yubico.Core/perf/Listeners/LegacyDesktopSmartCardDeviceListener.cs @@ -0,0 +1,118 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; +using Yubico.Core.Devices.SmartCard; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Performance.Legacy +{ + /// + /// Simplified snapshot of the pre-#445 (develop) DesktopSmartCardDeviceListener behavior + /// that exhibits the busy-loop bug when SCARD_E_INVALID_HANDLE is returned repeatedly. + /// + /// This is NOT a full port of the 513-line develop file, but rather a minimal distillation + /// that preserves the essential characteristic: no recovery path for SCARD_E_INVALID_HANDLE, + /// so GetStatusChange is called in a tight loop with no sleep when the context is dead. + /// + /// Used exclusively for BenchmarkDotNet performance comparison to prove PR #445's fix. + /// + internal sealed class LegacyDesktopSmartCardDeviceListener : SmartCardDeviceListener + { + private readonly ISCardInterop _scard; + private SCardContext _context; + private Thread? _listenerThread; + private volatile bool _isListening; + + public LegacyDesktopSmartCardDeviceListener(ISCardInterop scard) + { + _scard = scard; + Status = DeviceListenerStatus.Stopped; + + uint result = _scard.EstablishContext(SCARD_SCOPE.USER, out SCardContext context); + if (result != ErrorCode.SCARD_S_SUCCESS) + { + context.Dispose(); + _context = new SCardContext(IntPtr.Zero); + Status = DeviceListenerStatus.Error; + return; + } + + _context = context; + StartListening(); + } + + private void StartListening() + { + _listenerThread = new Thread(BusyLoopOnInvalidHandle) + { + IsBackground = true + }; + _isListening = true; + Status = DeviceListenerStatus.Started; + _listenerThread.Start(); + } + + /// + /// Simplified representation of the pre-#445 busy-loop bug: when GetStatusChange + /// returns SCARD_E_INVALID_HANDLE, the listener does NOT re-establish context or + /// sleep — it immediately calls GetStatusChange again, resulting in a tight loop. + /// + private void BusyLoopOnInvalidHandle() + { + // Probe call to determine UsePnpWorkaround (mimics the real code's first call) + var probeStates = SCARD_READER_STATE.CreateFromReaderNames(new[] { "\\\\?\\Pnp\\Notifications" }); + _ = _scard.GetStatusChange(_context, 0, probeStates, probeStates.Length); + + var readerStates = SCARD_READER_STATE.CreateFromReaderNames(new[] { "\\\\?\\Pnp\\Notifications" }); + + while (_isListening) + { + // This is the essence of the pre-#445 bug: + // - Call GetStatusChange with 100ms timeout + // - If it returns SCARD_E_INVALID_HANDLE, the switch/if logic in the old code + // does NOT recognize it as recoverable, so control returns to the top of + // the while loop immediately + // - No Thread.Sleep, no context re-establishment → busy spin + uint result = _scard.GetStatusChange(_context, 100, readerStates, readerStates.Length); + + // Pre-#445 logic only handled SCARD_E_TIMEOUT and a few other codes. + // SCARD_E_INVALID_HANDLE was NOT in the recoverable list, so the loop + // continues spinning. This simplified version makes that explicit: + if (result == ErrorCode.SCARD_E_TIMEOUT) + { + // Normal case: timeout, continue polling + continue; + } + + // For any other error (including SCARD_E_INVALID_HANDLE), the old code + // did not sleep or recover — it just kept looping. That's the bug. + // Here we explicitly do nothing, which causes the tight loop. + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _isListening = false; + _ = _scard.Cancel(_context); + _listenerThread?.Join(TimeSpan.FromSeconds(2)); + _context.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/Yubico.Core/perf/Mocks/AlwaysInvalidHandleScardInterop.cs b/Yubico.Core/perf/Mocks/AlwaysInvalidHandleScardInterop.cs new file mode 100644 index 000000000..d9afea36c --- /dev/null +++ b/Yubico.Core/perf/Mocks/AlwaysInvalidHandleScardInterop.cs @@ -0,0 +1,73 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Performance.Mocks +{ + /// + /// Test mock implementing that simulates persistent + /// SCARD_E_INVALID_HANDLE failures. Used for performance benchmarking of + /// recovery-path behavior. + /// + internal sealed class AlwaysInvalidHandleScardInterop : ISCardInterop + { + private int _establishContextCallCount; + private int _getStatusChangeCallCount; + private int _postProbeInvocations; + + /// + /// Total number of GetStatusChange calls after the initial probe. + /// Thread-safe for reading from the benchmark thread after observation window. + /// + public int Invocations => Volatile.Read(ref _postProbeInvocations); + + public uint EstablishContext(SCARD_SCOPE scope, out SCardContext context) + { + int callNum = Interlocked.Increment(ref _establishContextCallCount); + // Return a distinct non-zero handle on success, matching real WinSCard behavior. + context = new SCardContext(new IntPtr(callNum)); + return ErrorCode.SCARD_S_SUCCESS; + } + + public uint GetStatusChange(SCardContext context, int timeout, SCARD_READER_STATE[] readerStates, int readerStatesCount) + { + int callNum = Interlocked.Increment(ref _getStatusChangeCallCount); + + // Call #1 is the UsePnpWorkaround probe (timeout=0). + // Return SCARD_E_TIMEOUT so UsePnpWorkaround returns false cleanly. + if (callNum == 1) + { + return ErrorCode.SCARD_E_TIMEOUT; + } + + // All subsequent calls return SCARD_E_INVALID_HANDLE and increment the counter. + Interlocked.Increment(ref _postProbeInvocations); + return ErrorCode.SCARD_E_INVALID_HANDLE; + } + + public uint ListReaders(SCardContext context, string[]? groups, out string[] readerNames) + { + readerNames = Array.Empty(); + return ErrorCode.SCARD_E_NO_READERS_AVAILABLE; + } + + public uint Cancel(SCardContext context) + { + return ErrorCode.SCARD_S_SUCCESS; + } + } +} diff --git a/Yubico.Core/perf/Program.cs b/Yubico.Core/perf/Program.cs new file mode 100644 index 000000000..dc1459ed0 --- /dev/null +++ b/Yubico.Core/perf/Program.cs @@ -0,0 +1,24 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using BenchmarkDotNet.Running; + +namespace Yubico.Core.Performance +{ + public static class Program + { + public static void Main(string[] args) => + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } +} diff --git a/Yubico.Core/perf/Yubico.Core.Performance.csproj b/Yubico.Core/perf/Yubico.Core.Performance.csproj new file mode 100644 index 000000000..df9d4084e --- /dev/null +++ b/Yubico.Core/perf/Yubico.Core.Performance.csproj @@ -0,0 +1,18 @@ + + + Exe + net8.0 + enable + latest + false + CA1303;CA1031;CA2007;CA1812;CA1707;CA1861;IDE0058;IDE0044 + true + ..\..\Yubico.NET.SDK.snk + + + + + + + + diff --git a/Yubico.Core/src/Yubico.Core.csproj b/Yubico.Core/src/Yubico.Core.csproj index bcdae7594..2d29d8393 100644 --- a/Yubico.Core/src/Yubico.Core.csproj +++ b/Yubico.Core/src/Yubico.Core.csproj @@ -55,6 +55,18 @@ limitations under the License. --> ..\..\Yubico.NET.SDK.snk $(DefineConstants);ENABLE_SENSITIVE_LOG + + + true + @@ -111,17 +123,17 @@ limitations under the License. --> - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + all @@ -129,13 +141,13 @@ limitations under the License. --> - + - + <_Parameter1>Yubico.Core.UnitTests,PublicKey=00240000048000001401000006020000002400005253413100080000010001003312c63e1417ad4652242148c599b55c50d3213c7610b4cc1f467b193bfb8d131de6686268a9db307fcef9efcd5e467483fe9015307e5d0cf9d2fd4df12f29a1c7a72e531d8811ca70f6c80c4aeb598c10bb7fc48742ab86aa7986b0ae9a2f4876c61e0b81eb38e5b549f1fc861c633206f5466bfde021cb08d094742922a8258b582c3bc029eab88c98d476dac6e6f60bc0016746293f5337c68b22e528931b6494acddf1c02b9ea3986754716a9f2a32c59ff3d97f1e35ee07ca2972b0269a4cde86f7b64f80e7c13152c0f84083b5cc4f06acc0efb4316ff3f08c79bc0170229007fb27c97fb494b22f9f7b07f45547e263a44d5a7fe7da6a945a5e47afc9 @@ -149,5 +161,10 @@ limitations under the License. --> <_Parameter1>DynamicProxyGenAssembly2,PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7 + + + <_Parameter1>Yubico.Core.Performance,PublicKey=00240000048000001401000006020000002400005253413100080000010001003312c63e1417ad4652242148c599b55c50d3213c7610b4cc1f467b193bfb8d131de6686268a9db307fcef9efcd5e467483fe9015307e5d0cf9d2fd4df12f29a1c7a72e531d8811ca70f6c80c4aeb598c10bb7fc48742ab86aa7986b0ae9a2f4876c61e0b81eb38e5b549f1fc861c633206f5466bfde021cb08d094742922a8258b582c3bc029eab88c98d476dac6e6f60bc0016746293f5337c68b22e528931b6494acddf1c02b9ea3986754716a9f2a32c59ff3d97f1e35ee07ca2972b0269a4cde86f7b64f80e7c13152c0f84083b5cc4f06acc0efb4316ff3f08c79bc0170229007fb27c97fb494b22f9f7b07f45547e263a44d5a7fe7da6a945a5e47afc9 + + diff --git a/Yubico.Core/src/Yubico/Core/Buffers/Base32.cs b/Yubico.Core/src/Yubico/Core/Buffers/Base32.cs index e50633c88..416a436b5 100644 --- a/Yubico.Core/src/Yubico/Core/Buffers/Base32.cs +++ b/Yubico.Core/src/Yubico/Core/Buffers/Base32.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Yubico AB +// Copyright 2026 Yubico AB // // Licensed under the Apache License, Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -200,7 +200,7 @@ private static ReadOnlySpan StripPadding(ReadOnlySpan encoded) int length = encoded.Length; if (length > 0) { - while (encoded[length - 1] == '=') + while (length > 0 && encoded[length - 1] == '=') { --length; } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs b/Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs similarity index 74% rename from Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs rename to Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs index 27fb1ca4f..3995dd06f 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs +++ b/Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs @@ -13,10 +13,11 @@ // limitations under the License. using System; +using System.Security.Cryptography; -namespace Yubico.YubiKey.Cryptography; +namespace Yubico.Core.Cryptography; -internal static class HkdfUtilities +public static class HkdfUtilities { private const int Sha256HashByteLength = 32; // SHA-256 hash length in bytes @@ -24,6 +25,12 @@ internal static class HkdfUtilities /// Derives a key using the HKDF (HMAC-based Key Derivation Function) /// as specified in RFC 5869 using SHA-256. /// + /// + /// Uses BCL HMACSHA256 directly. The .ToArray() calls on Span inputs are + /// required by the BCL HMAC.Key setter and ComputeHash API — they only + /// accept byte[], not Span. The intermediate pseudo-random key (PRK) is + /// zeroed via CryptographicOperations.ZeroMemory after use. + /// /// The input key material (IKM). /// Optional salt value. If not provided, a zero-length /// salt will be used. @@ -47,19 +54,27 @@ public static Memory DeriveKey( throw new ArgumentOutOfRangeException(nameof(length), "Length exceeds maximum output size."); } - var pseudoRandomKey = HkdfExtract(inputKeyMaterial, salt); - return HkdfExpand(pseudoRandomKey, contextInfo, length); + byte[] pseudoRandomKey = HkdfExtract(inputKeyMaterial, salt); + try + { + return HkdfExpand(pseudoRandomKey, contextInfo, length); + } + finally + { + CryptographicOperations.ZeroMemory(pseudoRandomKey); + } } - private static ReadOnlyMemory HkdfExtract(ReadOnlySpan inputKeyMaterial, ReadOnlySpan salt) + private static byte[] HkdfExtract(ReadOnlySpan inputKeyMaterial, ReadOnlySpan salt) { - using var hmac = CryptographyProviders.HmacCreator("HMACSHA256"); - hmac.Key = salt.IsEmpty ? new byte[Sha256HashByteLength] : salt.ToArray(); + // BCL HMACSHA256 requires byte[] for key — .ToArray() is unavoidable here + byte[] saltBytes = salt.IsEmpty ? new byte[Sha256HashByteLength] : salt.ToArray(); + using var hmac = new HMACSHA256(saltBytes); return hmac.ComputeHash(inputKeyMaterial.ToArray()); } private static Memory HkdfExpand( - ReadOnlyMemory pseudoRandomKey, + ReadOnlySpan pseudoRandomKey, ReadOnlySpan contextInfo, int length) { @@ -67,10 +82,9 @@ private static Memory HkdfExpand( byte[] outputKeyMaterial = new byte[length]; Span previousBlock = Array.Empty(); - using var hmac = CryptographyProviders.HmacCreator("HMACSHA256"); + // BCL HMACSHA256 requires byte[] for key — .ToArray() is unavoidable here + using var hmac = new HMACSHA256(pseudoRandomKey.ToArray()); - hmac.Key = pseudoRandomKey.ToArray(); - for (byte index = 1; index <= numberOfBlocks; index++) { hmac.Initialize(); @@ -94,7 +108,7 @@ private static Memory HkdfExpand( currentBlock .AsSpan(0, bytesToCopy) .CopyTo(outputKeyMaterial.AsSpan(blockOffset)); - + previousBlock = currentBlock; } diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/HidLoggerExtensions.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/HidLoggerExtensions.cs index 2135afe9f..e01b610fc 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/HidLoggerExtensions.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/HidLoggerExtensions.cs @@ -23,7 +23,7 @@ public static void IOKitApiCall(this ILogger logger, string apiName, kern_return { if (result == kern_return_t.KERN_SUCCESS) { - logger.LogInformation("{APIName} called successfully.", apiName); + logger.LogDebug("{APIName} called successfully.", apiName); } else { diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidDevice.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidDevice.cs index 5a81c47e5..97efac86f 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidDevice.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidDevice.cs @@ -330,7 +330,7 @@ public override IHidConnection ConnectToIOReports() public void LogDeviceAccessTime() { LastAccessed = DateTime.Now; - _log.LogInformation("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); + _log.LogDebug("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); } } } diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidIOReportConnection.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidIOReportConnection.cs index 81a28ff4f..bdab8eb2e 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidIOReportConnection.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/LinuxHidIOReportConnection.cs @@ -60,7 +60,7 @@ public LinuxHidIOReportConnection(LinuxHidDevice device, string devnode) // exactly 64 bytes long. public void SetReport(byte[] report) { - _log.SensitiveLogInformation("Sending IO report> {report}, Length = {length}", Hex.BytesToHex(report), report.Length); + _log.SensitiveLogDebug("Sending IO report> {report}, Length = {length}", Hex.BytesToHex(report), report.Length); if (report.Length != YubiKeyIOReportSize) { throw new InvalidOperationException( @@ -105,7 +105,7 @@ public byte[] GetReport() if (bytesRead >= 0) { - _log.SensitiveLogInformation("Receiving IO report< {report}", Hex.BytesToHex(outputBuffer)); + _log.SensitiveLogDebug("Receiving IO report< {report}", Hex.BytesToHex(outputBuffer)); return outputBuffer; } diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidDevice.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidDevice.cs index 773ffc374..e7aefa101 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidDevice.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidDevice.cs @@ -136,7 +136,7 @@ internal static long GetEntryId(IntPtr device) public void LogDeviceAccessTime() { LastAccessed = DateTime.Now; - _log.LogInformation("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); + _log.LogDebug("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); } } } diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidFeatureReportConnection.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidFeatureReportConnection.cs index fd2ee56bc..03fb07211 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidFeatureReportConnection.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidFeatureReportConnection.cs @@ -147,7 +147,7 @@ public byte[] GetReport() ExceptionMessages.IOKitOperationFailed); } - _log.SensitiveLogInformation( + _log.SensitiveLogDebug( "GetReport returned buffer: {Report}", Hex.BytesToHex(buffer)); @@ -165,7 +165,7 @@ public byte[] GetReport() /// public void SetReport(byte[] report) { - _log.SensitiveLogInformation( + _log.SensitiveLogDebug( "Calling SetReport with data: {Report}", Hex.BytesToHex(report)); diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidIOReportConnection.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidIOReportConnection.cs index ffc644e29..5a4203329 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidIOReportConnection.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/MacOSHidIOReportConnection.cs @@ -172,7 +172,7 @@ public byte[] GetReport() { // If there's already a report in the queue (i.e. the callback beat us to calling GetReport) return // that one immediately. - _log.SensitiveLogInformation( + _log.SensitiveLogDebug( "GetReport returned buffer: {Report}", Hex.BytesToHex(report)); @@ -207,7 +207,7 @@ public byte[] GetReport() // and the PlatformApiException above would have been thrown. _ = _reportsQueue.TryDequeue(out report); - _log.SensitiveLogInformation( + _log.SensitiveLogDebug( "GetReport returned buffer: {Report}", Hex.BytesToHex(report)); @@ -249,7 +249,7 @@ private static void ReportCallback( { ILogger logger = Logging.Log.GetLogger(typeof(MacOSHidIOReportConnection).FullName!); - logger.LogInformation("MacOSHidIOReportConnection.ReportCallback has been called."); + logger.LogDebug("MacOSHidIOReportConnection.ReportCallback has been called."); if (result != 0 || type != IOKitHidConstants.kIOHidReportTypeInput || reportId != 0 || reportLength < 0) { @@ -298,7 +298,7 @@ public void SetReport(byte[] report) throw new ArgumentNullException(nameof(report)); } - _log.SensitiveLogInformation( + _log.SensitiveLogDebug( "Calling SetReport with data: {Report}", Hex.BytesToHex(report)); diff --git a/Yubico.Core/src/Yubico/Core/Devices/Hid/WindowsHidDevice.cs b/Yubico.Core/src/Yubico/Core/Devices/Hid/WindowsHidDevice.cs index 02d21b5d1..249623e9e 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/Hid/WindowsHidDevice.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/Hid/WindowsHidDevice.cs @@ -117,7 +117,7 @@ public override IHidConnection ConnectToIOReports() => public void LogDeviceAccessTime() { LastAccessed = DateTime.Now; - _log.LogInformation("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); + _log.LogDebug("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); } } } diff --git a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDevice.cs b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDevice.cs index ea229e9a6..e8fe89a22 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDevice.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDevice.cs @@ -196,7 +196,7 @@ public override ISmartCardConnection Connect() public void LogDeviceAccessTime() { LastAccessed = DateTime.Now; - _log.LogInformation("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); + _log.LogDebug("Updating last used for {Device} to {LastAccessed:hh:mm:ss.fffffff}", this, LastAccessed); } } diff --git a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListener.cs b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListener.cs index bff2b7a04..e64ad8874 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListener.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListener.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Yubico AB +// Copyright 2025 Yubico AB // // Licensed under the Apache License, Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -20,8 +20,6 @@ using Microsoft.Extensions.Logging; using Yubico.PlatformInterop; -using static Yubico.PlatformInterop.NativeMethods; - namespace Yubico.Core.Devices.SmartCard { /// @@ -31,6 +29,7 @@ internal class DesktopSmartCardDeviceListener : SmartCardDeviceListener { private static readonly string[] readerNames = new[] { "\\\\?\\Pnp\\Notifications" }; private readonly ILogger _log = Logging.Log.GetLogger(); + private readonly ISCardInterop _scard; // The resource manager context. private SCardContext _context; @@ -39,26 +38,47 @@ internal class DesktopSmartCardDeviceListener : SmartCardDeviceListener private SCARD_READER_STATE[] _readerStates; private Thread? _listenerThread; - private bool _isListening; + private volatile bool _isListening; private bool _isDisposed; private readonly object _startStopLock = new object(); private readonly object _disposeLock = new object(); + private readonly ManualResetEventSlim _stopRequested = new ManualResetEventSlim(false); private static readonly TimeSpan MaxDisposalWaitTime = TimeSpan.FromSeconds(8); private static readonly TimeSpan CheckForChangesWaitTime = TimeSpan.FromMilliseconds(100); + // How long to back off after a recoverable SCard error before retrying. + // Prevents a tight polling loop when SCardGetStatusChange returns immediately (e.g. + // SCARD_E_INVALID_HANDLE in an RDS environment). See GitHub issue #434. + private static readonly TimeSpan RecoveryBackoffDelay = TimeSpan.FromMilliseconds(1000); + private static readonly TimeSpan MaxRecoveryBackoffDelay = TimeSpan.FromSeconds(30); + private int _consecutiveRecoveryAttempts; + + /// + /// Constructs a using the system SCard implementation. + /// + public DesktopSmartCardDeviceListener() : this(new SCardInterop()) + { + } + /// - /// Constructs a . + /// Internal constructor that accepts a test double for the SCard API surface. /// - public DesktopSmartCardDeviceListener() + internal DesktopSmartCardDeviceListener(ISCardInterop scard) { + if (scard is null) + { + throw new ArgumentNullException(nameof(scard)); + } + + _scard = scard; _log.LogInformation("Creating DesktopSmartCardDeviceListener."); Status = DeviceListenerStatus.Stopped; - uint result = SCardEstablishContext(SCARD_SCOPE.USER, out SCardContext context); - _log.SCardApiCall(nameof(SCardEstablishContext), result); + uint result = _scard.EstablishContext(SCARD_SCOPE.USER, out SCardContext context); + _log.SCardApiCall(nameof(NativeMethods.SCardEstablishContext), result); // If we failed to establish context to the smart card subsystem, something substantially wrong - // has occured. We should not continue, and the device listener should remain dormant. + // has occurred. We should not continue, and the device listener should remain dormant. if (result != ErrorCode.SCARD_S_SUCCESS) { context.Dispose(); // Needed to satisfy analyzer (even though it should be null already) @@ -116,12 +136,14 @@ private void ListenForReaderChanges() if (!result) { break; - } + } } catch (Exception e) { _log.LogError(e, "Exception occurred while listening for smart card reader changes."); Status = DeviceListenerStatus.Error; + _ = _stopRequested.Wait(CalculateRecoveryBackoff(_consecutiveRecoveryAttempts)); + _consecutiveRecoveryAttempts++; } } } @@ -148,7 +170,7 @@ protected override void Dispose(bool disposing) if (disposing) { // Cancel any blocking SCardGetStatusChange calls - _ = SCardCancel(_context); + _ = _scard.Cancel(_context); // Stop the listener thread BEFORE disposing the context // This ensures the thread can exit gracefully while context is still valid @@ -156,6 +178,7 @@ protected override void Dispose(bool disposing) // Now it's safe to dispose the context _context.Dispose(); + _stopRequested.Dispose(); } } catch (Exception ex) @@ -190,6 +213,7 @@ private void StopListening() _isListening = false; Status = DeviceListenerStatus.Stopped; + _stopRequested.Set(); // Wait for thread to exit with timeout to prevent indefinite blocking bool exited = threadToJoin.Join(MaxDisposalWaitTime); @@ -208,12 +232,34 @@ private bool CheckForUpdates(bool usePnpWorkaround) bool sendEvents = CheckForChangesWaitTime != TimeSpan.Zero; var newStates = (SCARD_READER_STATE[])_readerStates.Clone(); - uint getStatusChangeResult = SCardGetStatusChange(_context, (int)CheckForChangesWaitTime.TotalMilliseconds, newStates, newStates.Length); + uint getStatusChangeResult = _scard.GetStatusChange(_context, (int)CheckForChangesWaitTime.TotalMilliseconds, newStates, newStates.Length); if (!HandleSCardGetStatusChangeResult(getStatusChangeResult, newStates)) { return false; } + // Timeout is normal polling behavior - nothing changed, so return immediately + // without further processing that could trigger spurious events. + // A timeout still proves the syscall path is healthy, so reset the recovery + // counter and clear the Error status (mirrors the success-path reset below). + if (getStatusChangeResult == ErrorCode.SCARD_E_TIMEOUT) + { + _consecutiveRecoveryAttempts = 0; + if (Status == DeviceListenerStatus.Error) + { + Status = DeviceListenerStatus.Started; + } + return true; + } + + // If a non-critical error triggered context recovery (UpdateCurrentContext refreshed + // _readerStates), short-circuit so the next loop iteration starts with fresh state. + // Without this, the stale newStates clone would overwrite _readerStates at the end. + if (getStatusChangeResult != ErrorCode.SCARD_S_SUCCESS) + { + return true; + } + while (ReaderListChangeDetected(ref newStates, usePnpWorkaround)) { SCARD_READER_STATE[] eventStateList = GetReaderStateList(); @@ -246,12 +292,18 @@ private bool CheckForUpdates(bool usePnpWorkaround) if (addedReaderStates.Length != 0) { _log.LogInformation("Additional smart card readers were found. Calling GetStatusChange for more information."); - getStatusChangeResult = SCardGetStatusChange(_context, 0, updatedStates, updatedStates.Length); + getStatusChangeResult = _scard.GetStatusChange(_context, 0, updatedStates, updatedStates.Length); if (!HandleSCardGetStatusChangeResult(getStatusChangeResult, updatedStates)) { return false; } + + if (getStatusChangeResult != ErrorCode.SCARD_S_SUCCESS + && getStatusChangeResult != ErrorCode.SCARD_E_TIMEOUT) + { + return true; + } } newStates = updatedStates; @@ -259,11 +311,17 @@ private bool CheckForUpdates(bool usePnpWorkaround) if (RelevantChangesDetected(newStates)) { - getStatusChangeResult = SCardGetStatusChange(_context, 0, newStates, newStates.Length); + getStatusChangeResult = _scard.GetStatusChange(_context, 0, newStates, newStates.Length); if (!HandleSCardGetStatusChangeResult(getStatusChangeResult, newStates)) { return false; } + + if (getStatusChangeResult != ErrorCode.SCARD_S_SUCCESS + && getStatusChangeResult != ErrorCode.SCARD_E_TIMEOUT) + { + return true; + } } if (sendEvents) @@ -277,6 +335,16 @@ private bool CheckForUpdates(bool usePnpWorkaround) FireEvents(arrivedDevices, removedDevices); + // A successful poll means the listener has recovered from any transient failure + // (e.g. a managed exception caught in ListenForReaderChanges that flipped Status + // to Error). Reset to Started so callers querying Status reflect live health. + // Also reset the exponential backoff counter. + _consecutiveRecoveryAttempts = 0; + if (Status == DeviceListenerStatus.Error) + { + Status = DeviceListenerStatus.Started; + } + return true; } @@ -292,9 +360,9 @@ private bool UsePnpWorkaround() try { SCARD_READER_STATE[] testState = SCARD_READER_STATE.CreateFromReaderNames(readerNames); - _ = SCardGetStatusChange(_context, 0, testState, testState.Length); + _ = _scard.GetStatusChange(_context, 0, testState, testState.Length); bool usePnpWorkaround = testState[0].EventState.HasFlag(SCARD_STATE.UNKNOWN); - return usePnpWorkaround; + return usePnpWorkaround; } catch (Exception e) { @@ -313,10 +381,10 @@ private bool ReaderListChangeDetected(ref SCARD_READER_STATE[] newStates, bool u { if (usePnpWorkaround) { - uint result = SCardListReaders(_context, null, out string[] readerNames); + uint result = _scard.ListReaders(_context, null, out string[] readerNames); if (result != ErrorCode.SCARD_E_NO_READERS_AVAILABLE) { - _log.SCardApiCall(nameof(SCardListReaders), result); + _log.SCardApiCall(nameof(NativeMethods.SCardListReaders), result); } return readerNames.Length != newStates.Length - 1; @@ -396,15 +464,42 @@ private static void UpdateCurrentlyKnownState(ref SCARD_READER_STATE[] states) } /// - /// Updates the current context. + /// Re-establishes the SCARDCONTEXT and refreshes the reader state list. /// + /// + /// The previous _context is disposed before SCardEstablishContext is called + /// to prevent handle-value recycling (see inline comment). On success, _context + /// is replaced with the new handle and _readerStates is refreshed. On failure + /// (e.g. Smart Card Service still restarting), _context is set to an invalid + /// (IntPtr.Zero) so the next poll re-enters recovery, + /// and a warning is logged. + /// private void UpdateCurrentContext() { - uint result = SCardEstablishContext(SCARD_SCOPE.USER, out SCardContext context); - _log.SCardApiCall(nameof(SCardEstablishContext), result); + // Dispose the old context BEFORE establishing a new one to prevent + // handle-value recycling: if the old handle was externally invalidated + // (e.g. RDS disconnect), Windows may recycle its IntPtr value for the + // new context, and disposing the old SafeHandle afterward would call + // SCardReleaseContext on the recycled value, destroying the new context. + if (!_context.IsInvalid && !_context.IsClosed) + { + _context.Dispose(); + } - _context = context; + uint result = _scard.EstablishContext(SCARD_SCOPE.USER, out SCardContext newContext); + _log.SCardApiCall(nameof(NativeMethods.SCardEstablishContext), result, knownRecoverable: true); + + if (result != ErrorCode.SCARD_S_SUCCESS) + { + newContext.Dispose(); + _context = new SCardContext(IntPtr.Zero); + _log.LogWarning("Failed to re-establish smart card context during recovery (error: {Error:X}).", result); + return; + } + + _context = newContext; _readerStates = GetReaderStateList(); + _log.LogDebug("Smart card context re-established successfully."); } /// @@ -413,10 +508,10 @@ private void UpdateCurrentContext() /// private SCARD_READER_STATE[] GetReaderStateList() { - uint result = SCardListReaders(_context, null, out string[] readerNames); + uint result = _scard.ListReaders(_context, null, out string[] readerNames); if (result != ErrorCode.SCARD_E_NO_READERS_AVAILABLE) { - _log.SCardApiCall(nameof(SCardListReaders), result); + _log.SCardApiCall(nameof(NativeMethods.SCardListReaders), result, knownRecoverable: true); } // We use this workaround as .NET 4.7 doesn't really support all of .NET Standard 2.0 @@ -476,32 +571,79 @@ private bool HandleSCardGetStatusChangeResult(uint result, SCARD_READER_STATE[] return true; } - // Log actual errors and reader states for debugging - _log.SCardApiCall(nameof(SCardGetStatusChange), result); - _log.LogInformation("Reader states:\n{States}", states); + // Log actual errors and reader states for debugging. + // Sleep briefly to prevent a tight loop if this error persists (e.g. unknown + // persistent error codes not yet classified as recoverable). + _log.SCardApiCall(nameof(NativeMethods.SCardGetStatusChange), result); + _log.LogInformation("Reader states:\n{States}", string.Join(Environment.NewLine, states.Select(s => s.ToString()))); + _ = _stopRequested.Wait(CalculateRecoveryBackoff(_consecutiveRecoveryAttempts)); + _consecutiveRecoveryAttempts++; return true; } /// - /// Checks if context need to be updated. + /// Attempts to recover from a non-critical SCard error by re-establishing the context. + /// Returns true if the error is recognised as recoverable and recovery was attempted. /// - /// - /// true if context updated + /// + /// + /// The following errors are handled: + /// + /// SCARD_E_INVALID_HANDLE — the SCARDCONTEXT handle was invalidated, most + /// commonly when a Windows Remote Desktop / terminal-server session is disconnected and + /// reconnected (GitHub issue #434). WinSCard internally raises a C++ exception for each + /// call with a stale handle, pegging a CPU core if the loop spins freely. + /// SCARD_E_SYSTEM_CANCELLED — the system cancelled the operation (e.g. RDS + /// session logoff or shutdown). + /// ERROR_BROKEN_PIPE — smart card operation attempted in a remote session + /// where the OS does not support smart card redirection (documented in SCardError.cs). + /// SCARD_E_SERVICE_STOPPED — the Smart Card Service stopped. + /// SCARD_E_NO_READERS_AVAILABLE — no readers present. + /// SCARD_E_NO_SERVICE — the Smart Card Service is not running. + /// + /// After re-establishing the context a sleep is applied + /// to prevent a tight polling loop if re-establishment also fails repeatedly (e.g. the + /// Smart Card Service is still transitioning). + /// + /// private bool UpdateContextIfNonCritical(uint errorCode) { switch (errorCode) { + case ErrorCode.SCARD_E_INVALID_HANDLE: // RDS session disconnect invalidates handle + case ErrorCode.SCARD_E_SYSTEM_CANCELLED: // RDS session logoff / system shutdown + case ErrorCode.ERROR_BROKEN_PIPE: // RDS: OS does not support smart card redirection case ErrorCode.SCARD_E_SERVICE_STOPPED: case ErrorCode.SCARD_E_NO_READERS_AVAILABLE: case ErrorCode.SCARD_E_NO_SERVICE: UpdateCurrentContext(); + // Back off before the next poll to avoid a tight loop when SCardGetStatusChange + // returns immediately (as it does with an invalid handle) and/or when the Smart + // Card Service is unavailable and EstablishContext also fails immediately. + _ = _stopRequested.Wait(CalculateRecoveryBackoff(_consecutiveRecoveryAttempts)); + _consecutiveRecoveryAttempts++; return true; default: return false; } } + /// + /// Calculates the exponential backoff delay for the current recovery attempt. + /// Doubles the base delay for each consecutive attempt, capped at 30 seconds. + /// + /// Number of consecutive recovery attempts (0-based). + /// The backoff delay for this attempt. + internal static TimeSpan CalculateRecoveryBackoff(int attempts) + { + int safeAttempts = Math.Min(Math.Max(attempts, 0), 10); // 2^10 cap-safe + long ticks = RecoveryBackoffDelay.Ticks * (1L << safeAttempts); + return ticks > MaxRecoveryBackoffDelay.Ticks + ? MaxRecoveryBackoffDelay + : TimeSpan.FromTicks(ticks); + } + private class ReaderStateComparer : IEqualityComparer { public bool Equals(SCARD_READER_STATE x, SCARD_READER_STATE y) => x.ReaderName == y.ReaderName; diff --git a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardDeviceListener.cs b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardDeviceListener.cs index 21b5ae48d..8e540c54f 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardDeviceListener.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardDeviceListener.cs @@ -50,7 +50,18 @@ public abstract class SmartCardDeviceListener : IDisposable /// /// A status that indicates the state of the device listener. /// - public DeviceListenerStatus Status { get; set; } + /// + /// Backed by a volatile int to ensure cross-thread visibility. The listener + /// thread writes this value during recovery transitions, while consumer code may read + /// it from the main thread. + /// + public DeviceListenerStatus Status + { + get => (DeviceListenerStatus)_status; + set => _status = (int)value; + } + + private volatile int _status; /// /// Creates an instance of a . diff --git a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensions.cs b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensions.cs index 2a893b8e9..9862a1318 100644 --- a/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensions.cs +++ b/Yubico.Core/src/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensions.cs @@ -27,6 +27,46 @@ public static void SCardApiCall(this ILogger logger, string apiName, uint result } } + /// + /// Logs an SCard API call result, with optional severity downgrade for known-recoverable errors. + /// + /// The logger instance. + /// The name of the SCard API that was called. + /// The result code returned by the API. + /// + /// When true and the result is not , + /// logs at Debug severity instead of Error severity. + /// Use this flag for errors that are expected in recovery paths (e.g., SCARD_E_INVALID_HANDLE + /// during context re-establishment after an RDS disconnect) to avoid flooding production logs + /// with error-level entries. + /// + public static void SCardApiCall(this ILogger logger, string apiName, uint result, bool knownRecoverable) + { + if (result == ErrorCode.SCARD_S_SUCCESS) + { + logger.LogInformation("{ApiName} called successfully.", apiName); + } + else + { + if (knownRecoverable) + { + logger.LogDebug( + "{ApiName} called and FAILED (known recoverable). Result = {Result:X} {Message}", + apiName, + result, + SCardException.GetErrorString(result)); + } + else + { + logger.LogError( + "{ApiName} called and FAILED. Result = {Result:X} {Message}", + apiName, + result, + SCardException.GetErrorString(result)); + } + } + } + public static void CardReset(this ILogger logger) => logger.LogWarning("The smart card was reset."); } diff --git a/Yubico.Core/src/Yubico/Core/Tlv/TlvObject.cs b/Yubico.Core/src/Yubico/Core/Tlv/TlvObject.cs index b04ca8036..880e8a84d 100644 --- a/Yubico.Core/src/Yubico/Core/Tlv/TlvObject.cs +++ b/Yubico.Core/src/Yubico/Core/Tlv/TlvObject.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Yubico AB +// Copyright 2026 Yubico AB // // Licensed under the Apache License, Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -181,10 +181,20 @@ internal static TlvObject ParseFrom(ref ReadOnlySpan buffer) int length = buffer[0]; buffer = buffer[1..]; + // Reject indefinite length form (0x80) — only determinate lengths are supported. + if (length == 0x80) + { + throw new TlvException(ExceptionMessages.TlvUnsupportedLengthField); + } + // If the length is more than one byte, process remaining bytes. if (length > 0x80) { int lengthLn = length - 0x80; + if (lengthLn > 4 || buffer.Length < lengthLn) + { + throw new TlvException(ExceptionMessages.TlvUnsupportedLengthField); + } length = 0; for (int i = 0; i < lengthLn; i++) { @@ -193,6 +203,11 @@ internal static TlvObject ParseFrom(ref ReadOnlySpan buffer) } } + if (buffer.Length < length) + { + throw new TlvException(ExceptionMessages.TlvUnexpectedEndOfBuffer); + } + ReadOnlySpan value = buffer[..length]; buffer = buffer[length..]; // Advance the buffer to the end of the value diff --git a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs index becb2efb6..6cdf8a2f2 100644 --- a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs +++ b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs @@ -84,5 +84,13 @@ public static int EcPointMul( q, m, IntPtr.Zero); + + // int EC_POINT_is_on_curve(const EC_GROUP* group, const EC_POINT* point, BN_CTX* ctx); + [DllImport(Libraries.NativeShims, EntryPoint = "Native_EC_POINT_is_on_curve", ExactSpelling = true, CharSet = CharSet.Ansi)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + private static extern int EcPointIsOnCurve(IntPtr group, IntPtr point, IntPtr ctx); + + public static int EcPointIsOnCurve(SafeEcGroup group, SafeEcPoint point) => + EcPointIsOnCurve(group.DangerousGetHandle(), point.DangerousGetHandle(), IntPtr.Zero); } } diff --git a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/ISCardInterop.cs b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/ISCardInterop.cs new file mode 100644 index 000000000..760318b47 --- /dev/null +++ b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/ISCardInterop.cs @@ -0,0 +1,39 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Yubico.PlatformInterop +{ + /// + /// Abstraction over the WinSCard / PCSC smart card API surface used by the device listener. + /// + /// + /// Exists primarily to enable injection of test doubles so that error-handling paths in + /// DesktopSmartCardDeviceListener can be exercised without requiring real smart card + /// hardware or a Windows terminal-services environment. + /// + internal interface ISCardInterop + { + /// Wraps SCardEstablishContext. + uint EstablishContext(SCARD_SCOPE scope, out SCardContext context); + + /// Wraps SCardGetStatusChange. + uint GetStatusChange(SCardContext context, int timeout, SCARD_READER_STATE[] readerStates, int readerStatesCount); + + /// Wraps the high-level SCardListReaders overload that handles the two-call Windows pattern. + uint ListReaders(SCardContext context, string[]? groups, out string[] readerNames); + + /// Wraps SCardCancel. + uint Cancel(SCardContext context); + } +} diff --git a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/SCardInterop.cs b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/SCardInterop.cs new file mode 100644 index 000000000..a28855ebb --- /dev/null +++ b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/SCard/SCardInterop.cs @@ -0,0 +1,35 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Yubico.PlatformInterop +{ + /// + /// Production implementation of that delegates directly to + /// P/Invoke declarations. + /// + internal sealed class SCardInterop : ISCardInterop + { + public uint EstablishContext(SCARD_SCOPE scope, out SCardContext context) => + NativeMethods.SCardEstablishContext(scope, out context); + + public uint GetStatusChange(SCardContext context, int timeout, SCARD_READER_STATE[] readerStates, int readerStatesCount) => + NativeMethods.SCardGetStatusChange(context, timeout, readerStates, readerStatesCount); + + public uint ListReaders(SCardContext context, string[]? groups, out string[] readerNames) => + NativeMethods.SCardListReaders(context, groups, out readerNames); + + public uint Cancel(SCardContext context) => + NativeMethods.SCardCancel(context); + } +} diff --git a/Yubico.Core/src/packages.lock.json b/Yubico.Core/src/packages.lock.json new file mode 100644 index 000000000..bbd890c3e --- /dev/null +++ b/Yubico.Core/src/packages.lock.json @@ -0,0 +1,1048 @@ +{ + "version": 1, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "CommunityToolkit.Diagnostics": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "WC0fk3A49gk6kWjhSZ8CDgPkv+wPHGhMFxUrKRlLtzUYXRFOdTDQ4RsEkZnI9ApO7eJh6nqT2MFmwFptpMs7aw==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "g0Xp9A+B8jCf5pNIIhFOQXPJkte3D87shfTLY+ylwfSh22U5oQH6tvvmcUuqJvt/wtwKk0WdNp2OGEczHJlJdg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.CodeAnalysis.NetAnalyzers": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "wAY0s+xokbBwVXxm6n7Q1kS4onWinN7qpV2RpkKXMQ0K1SGNsAy46mUFR5SReLQjy5ib9U8bfpnVUEiyZplA1A==" + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "10.0.7", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Configuration": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Buffers": "4.6.1", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "R4Tvr1oACImMS+Y5M7NM07ll9QyJSKnki3Dvz8QwG1W6FEmd+9fmZXAF6BE6UPswHF6n0v41wgMQGlaudOspqA==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.203", + "Microsoft.SourceLink.Common": "10.0.203", + "System.IO.Hashing": "10.0.7" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g==" + }, + "System.Memory": { + "type": "Direct", + "requested": "[4.6.3, )", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Security.Principal.Windows": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "Yubico.NativeShims": { + "type": "Direct", + "requested": "[1.*-*, )", + "resolved": "1.16.1-prerelease.20260428.1", + "contentHash": "6zw5SNFpwfYS4hmqNyIb06oPBmipwcz5KZRYhS8AvZkOWRASUKpcePMYuhNyPSVZLWIr+KQOEiUUPWDPBpLYgw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "m56WtzvIcL6t7JR3c7ogYitHizNM2QnRSo8yqxrQi+m5E/GGyDEmqymP+2p6YsFXn0j/Tzz67s4FQnrTLC7GKQ==", + "dependencies": { + "System.IO.Hashing": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7", + "System.ValueTuple": "4.6.2" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Diagnostics.DiagnosticSource": "10.0.7", + "System.ValueTuple": "4.6.2" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7", + "System.ValueTuple": "4.6.2" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net472": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "QYAnhBCOkT3ZUT/fHag11+bamwlbZ3U9Vi/WfKrD9emdUf1t3aqjWv0V2KtEGHSRSC81aBc8Oy/mvyGpEYd9Pg==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Fu6AxFf9bHz/Q7DQmxKC0o+UgFes8bs2Xh+PH/x31yExRAOASTwlzjZsISTtqVU5gQshKHLZopxEBTaIyfv0wg==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "6hsjdSr4VOXSOnhALkYplHpAxnTG1J33YN42IB6nH2fEg4QnJqrZ4Ft+qn7mkrKAOYC8pCSFYwVWw6rQbmwgLQ==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "LTxXYYKmRhPKWveYmfzuRTUnzsfY7CN+WOq6aTRgYE9vJ8BUvIWPCaSx4HxqBwXViTPSjR9cHDOVuVPuZGRR/Q==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "WUH+viO8VDG8NpFKvOBwpeyKUiPOMz3kQpA6AKCD4b2NG1pBhyC4AwTb357iZmTxZDnkM4IsFnvzN8W8OKmsHg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "F8Pu2QLUMeniVbtiyk7n7LCfFYxlcJ8ASaSwglJyq6dxa34iCQrikQszsgJClIJWuSWjcyhKkV7daAzYJqeVwA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "System.Buffers": "4.6.1", + "System.IO.Pipelines": "10.0.7", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encodings.Web": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3", + "System.ValueTuple": "4.6.2" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.6.2", + "contentHash": "yQgmjfFximrNm9LIV3mL6T5MzjeC+epeE5rl4hXxAlYmxby7RM1dPSkIKXk9HNkl6G54h2JHOmLD46+Pey+IRg==" + } + }, + ".NETStandard,Version=v2.0": { + "CommunityToolkit.Diagnostics": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "WC0fk3A49gk6kWjhSZ8CDgPkv+wPHGhMFxUrKRlLtzUYXRFOdTDQ4RsEkZnI9ApO7eJh6nqT2MFmwFptpMs7aw==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "g0Xp9A+B8jCf5pNIIhFOQXPJkte3D87shfTLY+ylwfSh22U5oQH6tvvmcUuqJvt/wtwKk0WdNp2OGEczHJlJdg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.CodeAnalysis.NetAnalyzers": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "wAY0s+xokbBwVXxm6n7Q1kS4onWinN7qpV2RpkKXMQ0K1SGNsAy46mUFR5SReLQjy5ib9U8bfpnVUEiyZplA1A==" + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "10.0.7", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Configuration": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Buffers": "4.6.1", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "R4Tvr1oACImMS+Y5M7NM07ll9QyJSKnki3Dvz8QwG1W6FEmd+9fmZXAF6BE6UPswHF6n0v41wgMQGlaudOspqA==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.203", + "Microsoft.SourceLink.Common": "10.0.203", + "System.IO.Hashing": "10.0.7" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g==" + }, + "System.Memory": { + "type": "Direct", + "requested": "[4.6.3, )", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Security.Principal.Windows": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "Yubico.NativeShims": { + "type": "Direct", + "requested": "[1.*-*, )", + "resolved": "1.16.1-prerelease.20260428.1", + "contentHash": "6zw5SNFpwfYS4hmqNyIb06oPBmipwcz5KZRYhS8AvZkOWRASUKpcePMYuhNyPSVZLWIr+KQOEiUUPWDPBpLYgw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "m56WtzvIcL6t7JR3c7ogYitHizNM2QnRSo8yqxrQi+m5E/GGyDEmqymP+2p6YsFXn0j/Tzz67s4FQnrTLC7GKQ==", + "dependencies": { + "System.IO.Hashing": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Diagnostics.DiagnosticSource": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "QYAnhBCOkT3ZUT/fHag11+bamwlbZ3U9Vi/WfKrD9emdUf1t3aqjWv0V2KtEGHSRSC81aBc8Oy/mvyGpEYd9Pg==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Fu6AxFf9bHz/Q7DQmxKC0o+UgFes8bs2Xh+PH/x31yExRAOASTwlzjZsISTtqVU5gQshKHLZopxEBTaIyfv0wg==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "6hsjdSr4VOXSOnhALkYplHpAxnTG1J33YN42IB6nH2fEg4QnJqrZ4Ft+qn7mkrKAOYC8pCSFYwVWw6rQbmwgLQ==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "LTxXYYKmRhPKWveYmfzuRTUnzsfY7CN+WOq6aTRgYE9vJ8BUvIWPCaSx4HxqBwXViTPSjR9cHDOVuVPuZGRR/Q==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "WUH+viO8VDG8NpFKvOBwpeyKUiPOMz3kQpA6AKCD4b2NG1pBhyC4AwTb357iZmTxZDnkM4IsFnvzN8W8OKmsHg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "F8Pu2QLUMeniVbtiyk7n7LCfFYxlcJ8ASaSwglJyq6dxa34iCQrikQszsgJClIJWuSWjcyhKkV7daAzYJqeVwA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "System.Buffers": "4.6.1", + "System.IO.Pipelines": "10.0.7", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encodings.Web": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + }, + ".NETStandard,Version=v2.1": { + "CommunityToolkit.Diagnostics": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "WC0fk3A49gk6kWjhSZ8CDgPkv+wPHGhMFxUrKRlLtzUYXRFOdTDQ4RsEkZnI9ApO7eJh6nqT2MFmwFptpMs7aw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "g0Xp9A+B8jCf5pNIIhFOQXPJkte3D87shfTLY+ylwfSh22U5oQH6tvvmcUuqJvt/wtwKk0WdNp2OGEczHJlJdg==" + }, + "Microsoft.Bcl.HashCode": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.CodeAnalysis.NetAnalyzers": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "wAY0s+xokbBwVXxm6n7Q1kS4onWinN7qpV2RpkKXMQ0K1SGNsAy46mUFR5SReLQjy5ib9U8bfpnVUEiyZplA1A==" + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "10.0.7", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Configuration": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Buffers": "4.6.1", + "System.Text.Json": "10.0.7" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.203, )", + "resolved": "10.0.203", + "contentHash": "R4Tvr1oACImMS+Y5M7NM07ll9QyJSKnki3Dvz8QwG1W6FEmd+9fmZXAF6BE6UPswHF6n0v41wgMQGlaudOspqA==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.203", + "Microsoft.SourceLink.Common": "10.0.203", + "System.IO.Hashing": "10.0.7" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g==" + }, + "System.Memory": { + "type": "Direct", + "requested": "[4.6.3, )", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Security.Principal.Windows": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "Yubico.NativeShims": { + "type": "Direct", + "requested": "[1.*-*, )", + "resolved": "1.16.1-prerelease.20260428.1", + "contentHash": "6zw5SNFpwfYS4hmqNyIb06oPBmipwcz5KZRYhS8AvZkOWRASUKpcePMYuhNyPSVZLWIr+KQOEiUUPWDPBpLYgw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "m56WtzvIcL6t7JR3c7ogYitHizNM2QnRSo8yqxrQi+m5E/GGyDEmqymP+2p6YsFXn0j/Tzz67s4FQnrTLC7GKQ==", + "dependencies": { + "System.IO.Hashing": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Physical": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "System.Diagnostics.DiagnosticSource": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.7", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.Configuration.Binder": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.203", + "contentHash": "QYAnhBCOkT3ZUT/fHag11+bamwlbZ3U9Vi/WfKrD9emdUf1t3aqjWv0V2KtEGHSRSC81aBc8Oy/mvyGpEYd9Pg==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "Fu6AxFf9bHz/Q7DQmxKC0o+UgFes8bs2Xh+PH/x31yExRAOASTwlzjZsISTtqVU5gQshKHLZopxEBTaIyfv0wg==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "6hsjdSr4VOXSOnhALkYplHpAxnTG1J33YN42IB6nH2fEg4QnJqrZ4Ft+qn7mkrKAOYC8pCSFYwVWw6rQbmwgLQ==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "LTxXYYKmRhPKWveYmfzuRTUnzsfY7CN+WOq6aTRgYE9vJ8BUvIWPCaSx4HxqBwXViTPSjR9cHDOVuVPuZGRR/Q==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "WUH+viO8VDG8NpFKvOBwpeyKUiPOMz3kQpA6AKCD4b2NG1pBhyC4AwTb357iZmTxZDnkM4IsFnvzN8W8OKmsHg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "F8Pu2QLUMeniVbtiyk7n7LCfFYxlcJ8ASaSwglJyq6dxa34iCQrikQszsgJClIJWuSWjcyhKkV7daAzYJqeVwA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.7", + "System.Buffers": "4.6.1", + "System.IO.Pipelines": "10.0.7", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encodings.Web": "10.0.7", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==" + } + } + } +} \ No newline at end of file diff --git a/Yubico.Core/tests/Yubico.Core.UnitTests.csproj b/Yubico.Core/tests/Yubico.Core.UnitTests.csproj index df3c0badc..827d55ffa 100644 --- a/Yubico.Core/tests/Yubico.Core.UnitTests.csproj +++ b/Yubico.Core/tests/Yubico.Core.UnitTests.csproj @@ -43,14 +43,14 @@ limitations under the License. --> Linux - + - + - + PreserveNewest diff --git a/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerSCardErrorTests.cs b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerSCardErrorTests.cs new file mode 100644 index 000000000..fe1ebea3d --- /dev/null +++ b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerSCardErrorTests.cs @@ -0,0 +1,641 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Reproduces GitHub issue #434: +// High idle CPU cost of enumerating devices in terminal server environments. +// +// Root cause: When an RDS session is disconnected, the Windows Smart Card Service invalidates +// existing SCARDCONTEXT handles. DesktopSmartCardDeviceListener continued to call +// SCardGetStatusChange with the stale handle, which internally raises and unwinds a C++ +// exception thousands of times per second, pegging a CPU core. +// +// Reproduction mechanism: FakeSCardInterop returns SCARD_E_INVALID_HANDLE from GetStatusChange +// to simulate what WinSCard returns after an RDS handle invalidation. The fake does not require +// Windows or a real smart card reader, and runs on all CI platforms. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Devices.SmartCard.UnitTests +{ + /// + /// Tests for smart card listener error handling, recovery, and backoff behavior. + /// + /// + /// + /// Timing-based assertion strategy: Several tests use Thread.Sleep to + /// observe listener behavior over a fixed window, then assert that call counts or + /// elapsed times fall within expected bounds. This is necessary because the listener + /// runs on a background thread with inherently asynchronous behavior. + /// + /// + /// Thresholds include significant headroom above expected values to avoid flaky failures + /// on slow CI runners: spin-prevention tests allow 15 calls in 600 ms (expected ~2, + /// headroom ~7x); dispose-speed tests allow 200 ms (expected <10 ms, headroom ~20x); + /// recovery tests observe for 2500 ms with assertion ">= 2 calls" (expected within ~1 s, + /// headroom ~2.5x). + /// + /// + /// The [Collection("SCardErrorTests")] attribute disables parallel execution + /// with other tests in this collection, preventing thread-pool contention from skewing + /// timing measurements. + /// + /// + [Collection("SCardErrorTests")] + public class DesktopSmartCardDeviceListenerSCardErrorTests + { + // ----------------------------------------------------------------------------------------- + // Issue #434 — SCARD_E_INVALID_HANDLE causes tight loop and high CPU + // + // This test FAILS before the fix and PASSES after. + // Before fix: SCARD_E_INVALID_HANDLE is not handled by UpdateContextIfNonCritical, so + // the listener logs the error and immediately retries, spinning at full speed. + // After fix: SCARD_E_INVALID_HANDLE triggers UpdateCurrentContext (re-establishes the + // SCARDCONTEXT) followed by Thread.Sleep(1000) to back off. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsInvalidHandle_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.SCARD_E_INVALID_HANDLE, + "SCARD_E_INVALID_HANDLE"); + + // ----------------------------------------------------------------------------------------- + // Issue #434 — Proof that SCARD_E_INVALID_HANDLE causes a tight polling loop (high CPU) + // + // This test quantifies the spin rate. When SCARD_E_INVALID_HANDLE is returned on every + // GetStatusChange call (simulating persistent handle invalidation as in RDS), the loop + // must NOT spin freely. The Thread.Sleep(1000) backoff introduced by the fix limits the + // rate to ~1 iteration per second. + // + // This test FAILS before the fix (spin -> hundreds of calls) and PASSES after. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeAlwaysReturnsInvalidHandle_LoopDoesNotSpin() + { + // Arrange: all GetStatusChange calls (after probe) return SCARD_E_INVALID_HANDLE. + // This simulates the worst case: handle remains invalid after each recovery attempt. + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_INVALID_HANDLE); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + // Act: observe for 600ms. + // Without fix: INVALID_HANDLE is ignored, loop spins at max speed — + // expect hundreds of GetStatusChange calls in 600ms. + // With fix: INVALID_HANDLE triggers recovery + Thread.Sleep(1000) — + // only 1–2 main poll calls fit in 600ms (probe + first main poll, then sleeping). + Thread.Sleep(600); + + int callCount = fake.GetStatusChangeCallCount; + + // Assert: fewer than 15 calls in 600ms proves no tight loop. + // With fix: expect ~2 (probe + first INVALID_HANDLE poll, then 1000ms sleep begins). + // Without fix: expect hundreds (unthrottled spin). + Assert.True( + callCount < 15, + $"GetStatusChange was called {callCount} times in ~600ms. " + + "Expected < 15: SCARD_E_INVALID_HANDLE must not cause an unthrottled polling loop. " + + "This is the high-CPU symptom reported in GitHub issue #434."); + } + + // ----------------------------------------------------------------------------------------- + // Unknown / unclassified error codes — fallthrough backoff path + // + // HandleSCardGetStatusChangeResult lines 570-578: error codes not matched by + // UpdateContextIfNonCritical fall through to a backoff sleep. This prevents a tight + // spin on persistent unrecognized errors (e.g. future WinSCard error codes). + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsUnknownError_LoopDoesNotSpin() + { + const uint unknownError = 0xDEADBEEF; + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: unknownError); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + Thread.Sleep(600); + + int callCount = fake.GetStatusChangeCallCount; + + Assert.True( + callCount < 15, + $"GetStatusChange was called {callCount} times in ~600ms. " + + "Expected < 15: unrecognised error codes must not cause an unthrottled polling loop."); + } + + // ----------------------------------------------------------------------------------------- + // Issue #434 — SCARD_E_SYSTEM_CANCELLED (RDS session disconnect/logoff) also recovers + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsSystemCancelled_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.SCARD_E_SYSTEM_CANCELLED, + "SCARD_E_SYSTEM_CANCELLED (RDS logoff/disconnect)"); + + // ----------------------------------------------------------------------------------------- + // Issue #434 — ERROR_BROKEN_PIPE (RDS smart card redirection not supported) also recovers + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsBrokenPipe_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.ERROR_BROKEN_PIPE, + "ERROR_BROKEN_PIPE (RDS smart card redirection error)"); + + // ----------------------------------------------------------------------------------------- + // Issue #434 — SCARD_E_SERVICE_STOPPED (Smart Card Service stopped) also recovers + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsServiceStopped_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.SCARD_E_SERVICE_STOPPED, + "SCARD_E_SERVICE_STOPPED (Smart Card Service stopped)"); + + // ----------------------------------------------------------------------------------------- + // Issue #434 — SCARD_E_NO_READERS_AVAILABLE (no readers present) also recovers + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsNoReadersAvailable_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.SCARD_E_NO_READERS_AVAILABLE, + "SCARD_E_NO_READERS_AVAILABLE (no readers present)"); + + // ----------------------------------------------------------------------------------------- + // Issue #434 — SCARD_E_NO_SERVICE (Smart Card Service not running) also recovers + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsNoService_ContextIsReestablished() + => AssertErrorTriggersContextReestablishment( + ErrorCode.SCARD_E_NO_SERVICE, + "SCARD_E_NO_SERVICE (Smart Card Service not running)"); + + /// + /// Verifies that a given non-critical SCard error triggers context re-establishment. + /// The error is scheduled as the first GetStatusChange result after the PnP probe. + /// + private static void AssertErrorTriggersContextReestablishment(uint errorCode, string errorName) + { + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + scheduledResults: new[] { errorCode }); + + using var listener = new DesktopSmartCardDeviceListener(fake); + Thread.Sleep(2500); + + Assert.True( + fake.EstablishContextCallCount >= 2, + $"EstablishContext was called {fake.EstablishContextCallCount} time(s). " + + $"Expected >= 2: {errorName} must trigger context re-establishment."); + } + + // ----------------------------------------------------------------------------------------- + // ISC-D — When context re-establishment itself fails, listener continues without crashing + // + // If SCardEstablishContext fails during recovery (Smart Card Service still unavailable), + // the listener must not crash, must not replace _context with a failed handle, and + // must continue attempting recovery (bounded by the 1000ms sleep between retries). + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenContextReestablishmentFails_ListenerContinuesWithoutCrashing() + { + // Arrange: first EstablishContext (construction) succeeds, + // subsequent EstablishContext calls (recovery) fail. + // GetStatusChange returns INVALID_HANDLE to trigger recovery. + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_INVALID_HANDLE, + establishContextFailAfterFirstCall: true); + + var exception = Record.Exception(() => + { + using var listener = new DesktopSmartCardDeviceListener(fake); + Thread.Sleep(2500); + // Listener should still be alive (Status is not Error due to exception) + Assert.NotEqual(DeviceListenerStatus.Error, listener.Status); + }); + + Assert.Null(exception); + } + + // ----------------------------------------------------------------------------------------- + // Follow-up step 1 — Status resets to Started after a recovered managed exception + // + // ListenForReaderChanges sets Status = Error in its catch (Exception) block but never + // resets it. After the listener recovers (next poll succeeds), Status must reflect live + // health (Started), not the stale Error value. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenPollSucceedsAfterManagedException_StatusResetsToStarted() + { + // Arrange: probe -> TIMEOUT (no PnP workaround). First post-probe poll throws, + // flipping Status to Error in ListenForReaderChanges' catch block. Subsequent + // polls return TIMEOUT (success path that reaches the end of CheckForUpdates). + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_TIMEOUT, + throwOnGetStatusChangeAfterProbe: true); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + // Wait long enough for: probe + throw (Status=Error) + 1000ms sleep + several successful polls. + Thread.Sleep(1500); + + Assert.Equal(DeviceListenerStatus.Started, listener.Status); + } + + // ----------------------------------------------------------------------------------------- + // Follow-up step 2 — ListenForReaderChanges catch block is throttled + // + // An unexpected managed exception from CheckForUpdates re-enters the while (_isListening) + // loop with no delay (pre-Step-2). This can cause a tight spin if the same exception + // recurs. Step 2 adds Thread.Sleep(RecoveryBackoffDelay) to throttle the retry. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenCatchBlockTriggers_LoopThrottlesBeforeRetry() + { + // Arrange: probe -> TIMEOUT, then every poll throws. + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_TIMEOUT, + throwOnGetStatusChangeAfterProbe: true); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + // Act: observe for ~600ms. Without throttle in the catch block, hundreds of + // GetStatusChange calls would occur. With throttle, only 1–2 fit in 600ms. + Thread.Sleep(600); + + int callCount = fake.GetStatusChangeCallCount; + + // Assert: fewer than 5 calls proves throttling is working. + // Expected: probe + 1 throw (~0ms) + 1000ms sleep → only ~2 calls total in 600ms. + Assert.True( + callCount < 5, + $"GetStatusChange was called {callCount} times in ~600ms. " + + "Expected < 5: catch block must throttle before retry."); + } + + // ----------------------------------------------------------------------------------------- + // Follow-up step 3 — Dispose unblocks immediately when listener is in recovery wait + // + // Thread.Sleep(RecoveryBackoffDelay) blocks Dispose for up to 1 second per active wait + // site. _scard.Cancel(_context) only wakes a blocked syscall, not a sleeping thread. + // Step 3 replaces Thread.Sleep with ManualResetEventSlim.Wait(timeout) so StopListening + // can signal the wait and Dispose returns immediately. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenDisposeCalledDuringRecoveryWait_DisposeReturnsQuickly() + { + // Arrange: schedule INVALID_HANDLE on every poll so the listener enters recovery wait. + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_INVALID_HANDLE); + + var listener = new DesktopSmartCardDeviceListener(fake); + + // Give the listener time to enter the recovery wait (probe + first INVALID_HANDLE poll + // + start of 1000ms wait). + Thread.Sleep(50); + + // Act: measure Dispose duration. + var sw = System.Diagnostics.Stopwatch.StartNew(); + listener.Dispose(); + sw.Stop(); + + // Assert: Dispose must return in under 200ms. + // Pre-Step-3: Dispose would block on the full 1000ms Thread.Sleep. + // Post-Step-3: _stopRequested.Set() wakes the wait immediately. + Assert.True( + sw.ElapsedMilliseconds < 200, + $"Dispose took {sw.ElapsedMilliseconds}ms. Expected < 200ms: " + + "recovery waits must be cancellation-aware so Dispose unblocks immediately."); + } + + // ----------------------------------------------------------------------------------------- + // Follow-up step 4 — Exponential backoff with cap + // + // Today's recovery path sleeps a fixed 1s. If WinSCard / Smart Card Service stays broken + // for minutes, the listener still polls every second. Exponential backoff with a cap + // gives up CPU more aggressively without losing eventual recovery. + // ----------------------------------------------------------------------------------------- + + [Theory] + [InlineData(0, 1000)] + [InlineData(1, 2000)] + [InlineData(2, 4000)] + [InlineData(3, 8000)] + [InlineData(4, 16000)] + [InlineData(5, 30000)] // capped + [InlineData(10, 30000)] // still capped + [InlineData(100, 30000)] // safe against overflow + public void CalculateRecoveryBackoff_DoublesUntilCap(int attempts, int expectedMs) + { + Assert.Equal( + TimeSpan.FromMilliseconds(expectedMs), + DesktopSmartCardDeviceListener.CalculateRecoveryBackoff(attempts)); + } + + // ----------------------------------------------------------------------------------------- + // Internal feedback (PR #460) — SCARD_E_TIMEOUT must NOT trigger arrival/removal events + // + // Internal testers reported that after PR #445 + the recovery hardening stack landed, + // YubiKeyDeviceListener was reprocessing the device tree every ~3 seconds with no actual + // hardware change. Trace showed CheckForUpdates continuing past a SCARD_E_TIMEOUT result, + // comparing CurrentState vs EventState on a stale clone and firing spurious Arrived/Removed + // events. The fix returns immediately from the poll iteration on SCARD_E_TIMEOUT. + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsTimeout_NoArrivalOrRemovalEventsFire() + { + // Arrange: every poll returns SCARD_E_TIMEOUT (the normal "nothing happened" outcome). + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_TIMEOUT); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + int arrivedCount = 0; + int removedCount = 0; + listener.Arrived += (_, _) => Interlocked.Increment(ref arrivedCount); + listener.Removed += (_, _) => Interlocked.Increment(ref removedCount); + + // Act: observe across several poll iterations (each poll is 100 ms). + Thread.Sleep(600); + + // Assert: timeouts must short-circuit before reaching DetectRelevantChanges/FireEvents. + Assert.Equal(0, Volatile.Read(ref arrivedCount)); + Assert.Equal(0, Volatile.Read(ref removedCount)); + } + + // ----------------------------------------------------------------------------------------- + // Internal feedback (PR #460) — SCARD_E_TIMEOUT still resets recovery health + // + // A SCARD_E_TIMEOUT proves the syscall path is healthy (the timeout fired naturally), so + // the early-return path must still clear DeviceListenerStatus.Error and reset the + // exponential-backoff counter — same as the SCARD_S_SUCCESS path at the end of CheckForUpdates. + // Without this, Step 1's Status-reset and Step 4's backoff-reset are skipped whenever + // recovery happens to be followed by quiet polling (the common case). + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenPollTimesOutAfterManagedException_StatusResetsToStarted() + { + // Arrange: probe -> TIMEOUT (no PnP workaround). First post-probe poll throws, + // flipping Status to Error. Subsequent polls return TIMEOUT (the realistic quiet case). + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_E_TIMEOUT, + throwOnGetStatusChangeAfterProbe: true); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + // Wait long enough for: probe + throw (Status=Error) + 1000ms sleep + several timeout polls. + Thread.Sleep(1500); + + Assert.Equal(DeviceListenerStatus.Started, listener.Status); + } + + // ----------------------------------------------------------------------------------------- + // Internal feedback (PR #460) — broader invariant: SCARD_STATE.CHANGED on the PnP reader + // without a real reader-list delta must NOT fire arrival/removal events + // + // This pins the invariant one level deeper than the SCARD_E_TIMEOUT short-circuit. The + // pre-#460 code returned SCARD_S_SUCCESS from GetStatusChange on a CHANGED toggle, entered + // the ReaderListChangeDetected branch, and was capable of producing spurious removal events + // from a stale-clone comparison. + // + // Honest scope note: the user's exact reported bug ("arrival → 110 ms later removal" every + // ~3 s) requires a real reader entry in _readerStates with PRESENT set in CurrentState, + // followed by an Except() mismatch against a freshly-fetched reader list that flips that + // entry's PRESENT bit. Reproducing that end-to-end in the mock harness would require + // recreating non-trivial WinSCard state-machine semantics in test code (multi-phase + // ListReaders responses, post-AcknowledgeChanges state transitions). This test pins the + // simpler — and still important — invariant: a SUCCESS poll where only the synthetic PnP + // reader has CHANGED set, with no underlying reader-list delta, must produce zero events. + // The user's specific repro path remains an integration-level scenario (real WinSCard or + // pcscd, real reader, real RDS state churn). + // ----------------------------------------------------------------------------------------- + + [Fact] + public void WhenGetStatusChangeReturnsChangedWithoutReaderDelta_NoEventsFire() + { + // Arrange: probe returns TIMEOUT (no PnP workaround), then every poll returns SUCCESS + // with the synthetic PnP reader having CHANGED set in EventState but no actual reader + // topology change (ListReaders still returns empty). This mirrors the upstream tick + // pattern the user observed at ~3 s intervals. + var fake = new FakeSCardInterop( + probeResult: ErrorCode.SCARD_E_TIMEOUT, + defaultResult: ErrorCode.SCARD_S_SUCCESS, + stateApplier: SetPnpReaderChangedFlag); + + using var listener = new DesktopSmartCardDeviceListener(fake); + + int arrivedCount = 0; + int removedCount = 0; + listener.Arrived += (_, _) => Interlocked.Increment(ref arrivedCount); + listener.Removed += (_, _) => Interlocked.Increment(ref removedCount); + + // Act: observe across several poll iterations (each 100 ms). With the bug, we'd see + // spurious paired arrival/removal events on every iteration where CHANGED was set + // and the stale-clone comparison fired. + Thread.Sleep(600); + + // Assert: no real device topology change occurred, so no events should fire — even + // though SUCCESS came back and CHANGED was set. + Assert.Equal(0, Volatile.Read(ref arrivedCount)); + Assert.Equal(0, Volatile.Read(ref removedCount)); + } + + /// + /// Mutates the PnP reader entry (always element 0 — see GetReaderStateList) to have + /// SCARD_STATE.CHANGED set in EventState while leaving CurrentState untouched. This + /// simulates the upstream "something happened in the reader topology" tick from + /// WinSCard / pcscd without actually changing the reader list ListReaders sees. + /// + /// + /// SCARD_READER_STATE's _eventState field is private (it is populated by P/Invoke from + /// the unmanaged WinSCard / pcscd layer). For testing we mutate it via reflection — the + /// cleanest alternative would be a SetStateForTesting helper on the struct itself, but + /// that pollutes the production type with a test-only seam. Reflection is contained to + /// this single helper. + /// + private static void SetPnpReaderChangedFlag(SCARD_READER_STATE[] states) + { + if (states.Length == 0) + { + return; + } + + FieldInfo eventStateField = typeof(SCARD_READER_STATE).GetField( + "_eventState", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SCARD_READER_STATE._eventState field not found."); + + // Box, mutate, unbox-back. Necessary because SCARD_READER_STATE is a value type and + // FieldInfo.SetValue on a struct array element requires going through a boxed copy. + object boxed = states[0]; + eventStateField.SetValue(boxed, (uint)SCARD_STATE.CHANGED); + states[0] = (SCARD_READER_STATE)boxed; + } + + // ───────────────────────────────────────────────────────────────────────────────────────── + // Test double + // ───────────────────────────────────────────────────────────────────────────────────────── + + /// + /// A deterministic fake of that lets tests control which + /// error codes GetStatusChange returns and count calls to each method. + /// Thread-safe: counters use volatile reads/writes from the listener thread. + /// + private sealed class FakeSCardInterop : ISCardInterop + { + private readonly uint _probeResult; + private readonly uint _defaultResult; + private readonly Queue _scheduledResults; + private readonly bool _establishContextFailAfterFirstCall; + private readonly bool _throwOnGetStatusChangeAfterProbe; + private readonly Action? _stateApplier; + + private int _establishContextCallCount; + private int _getStatusChangeCallCount; + private int _hasThrownOnce; + + /// Total calls to EstablishContext. Safe to read from test thread after Thread.Sleep. + public int EstablishContextCallCount => Volatile.Read(ref _establishContextCallCount); + + /// Total calls to GetStatusChange (includes the UsePnpWorkaround probe). + public int GetStatusChangeCallCount => Volatile.Read(ref _getStatusChangeCallCount); + + /// + /// Return value for the very first GetStatusChange call (UsePnpWorkaround probe). + /// Defaults to SCARD_E_TIMEOUT so the probe indicates no PnP workaround needed. + /// + /// + /// Return value for all GetStatusChange calls once + /// is exhausted. Defaults to SCARD_E_TIMEOUT (normal polling). + /// + /// + /// Ordered sequence of return values for GetStatusChange calls after the probe. + /// Values are consumed in order; after the queue is empty, is used. + /// + /// + /// When true, the second and subsequent calls to EstablishContext return + /// SCARD_E_NO_SERVICE to simulate the Smart Card Service being unavailable during recovery. + /// + /// + /// When true, the first GetStatusChange call after the probe throws + /// InvalidOperationException to simulate a managed exception escaping into + /// ListenForReaderChanges' catch block. Subsequent calls behave normally. + /// + /// + /// Optional callback invoked on every post-probe GetStatusChange call. The callback + /// receives the listener's newStates array (as cloned from _readerStates) + /// and may mutate it in place to simulate WinSCard / pcscd populating reader state + /// flags (e.g. SCARD_STATE.CHANGED) before returning. Required for tests that + /// need to exercise the state-comparison paths inside CheckForUpdates. + /// + public FakeSCardInterop( + uint probeResult = ErrorCode.SCARD_E_TIMEOUT, + uint defaultResult = ErrorCode.SCARD_E_TIMEOUT, + uint[]? scheduledResults = null, + bool establishContextFailAfterFirstCall = false, + bool throwOnGetStatusChangeAfterProbe = false, + Action? stateApplier = null) + { + _probeResult = probeResult; + _defaultResult = defaultResult; + _scheduledResults = scheduledResults is null + ? new Queue() + : new Queue(scheduledResults); + _establishContextFailAfterFirstCall = establishContextFailAfterFirstCall; + _throwOnGetStatusChangeAfterProbe = throwOnGetStatusChangeAfterProbe; + _stateApplier = stateApplier; + } + + public uint EstablishContext(SCARD_SCOPE scope, out SCardContext context) + { + int callNum = Interlocked.Increment(ref _establishContextCallCount); + + if (_establishContextFailAfterFirstCall && callNum > 1) + { + context = new SCardContext(IntPtr.Zero); + return ErrorCode.SCARD_E_NO_SERVICE; + } + + // Return a distinct non-zero handle on success, matching real WinSCard behavior. + context = new SCardContext(new IntPtr(callNum)); + return ErrorCode.SCARD_S_SUCCESS; + } + + public uint GetStatusChange(SCardContext context, int timeout, SCARD_READER_STATE[] states, int count) + { + int callNum = Interlocked.Increment(ref _getStatusChangeCallCount); + + // Call #1 is always the UsePnpWorkaround probe (timeout=0). + if (callNum == 1) + { + return _probeResult; + } + + if (_throwOnGetStatusChangeAfterProbe + && Interlocked.Exchange(ref _hasThrownOnce, 1) == 0) + { + throw new InvalidOperationException("Simulated managed exception in GetStatusChange."); + } + + // Allow tests to mutate the reader state in place before returning, mirroring how + // real WinSCard / pcscd populates _eventState during a successful poll. + _stateApplier?.Invoke(states); + + lock (_scheduledResults) + { + if (_scheduledResults.Count > 0) + { + return _scheduledResults.Dequeue(); + } + } + + return _defaultResult; + } + + public uint ListReaders(SCardContext context, string[]? groups, out string[] readerNames) + { + // Return empty reader list — no readers is valid and avoids allocating real state. + readerNames = Array.Empty(); + return ErrorCode.SCARD_E_NO_READERS_AVAILABLE; + } + + public uint Cancel(SCardContext context) => ErrorCode.SCARD_S_SUCCESS; + } + } +} diff --git a/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerWindowsTests.cs b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerWindowsTests.cs new file mode 100644 index 000000000..57b122d0a --- /dev/null +++ b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/DesktopSmartCardDeviceListenerWindowsTests.cs @@ -0,0 +1,258 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Track A — Windows integration test for GitHub issue #434. +// +// PURPOSE +// ------- +// The Track B mock tests (DesktopSmartCardDeviceListenerSCardErrorTests.cs) prove that the +// managed polling loop is throttled after the fix. They do NOT exercise the actual CPU-intensive +// mechanism reported in the bug: WinSCard.dll internally raising and unwinding a C++ exception +// (CxxThrowException / RtlRaiseException / RtlUnwindEx) for every call made with an invalid +// SCARDCONTEXT handle. +// +// This file contains tests that close that gap by: +// 1. Creating a real listener backed by the production SCardInterop / WinSCard.dll. +// 2. Programmatically invalidating the SCARDCONTEXT handle via SCardReleaseContext, which +// produces exactly the same invalid-handle condition that an RDS session disconnect creates. +// 3. Measuring real CPU consumption (Process.TotalProcessorTime) to prove the symptom +// (pegged CPU core) exists before the fix and is eliminated after the fix. +// +// REQUIREMENTS +// ------------ +// - Windows host (any edition with Smart Card service running — no physical reader needed). +// - The Smart Card service (SCardSvr) must be in Running state. It is enabled by default on +// Windows 10/11 and Windows Server. If disabled, SCardEstablishContext will fail and the +// listener will enter dormant/Error status — the tests will skip gracefully. +// - Run in isolation: the CPU measurement is sensitive to concurrent test thread activity. +// The [Collection("WindowsOnlyTests")] attribute ensures xUnit serializes these tests. +// +// HOW TO RUN ON YOUR WINDOWS MACHINE +// ------------------------------------ +// dotnet test Yubico.Core/tests/Yubico.Core.UnitTests.csproj +// --filter "FullyQualifiedName~DesktopSmartCardDeviceListenerWindowsTests" +// --logger "console;verbosity=detailed" + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Devices.SmartCard.UnitTests +{ + [CollectionDefinition("WindowsOnlyTests", DisableParallelization = true)] + public class WindowsOnlyTestsCollection { } + + [Collection("WindowsOnlyTests")] + public class DesktopSmartCardDeviceListenerWindowsTests + { + // ───────────────────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────────────────── + + /// + /// Returns the SCARDCONTEXT handle held by a running listener via reflection. + /// + private static SCardContext GetListenerContext(SmartCardDeviceListener listener) + { + var field = listener.GetType() + .GetField("_context", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException( + "_context field not found — listener type may have changed."); + + return (SCardContext)(field.GetValue(listener) + ?? throw new InvalidOperationException("_context is null.")); + } + + /// + /// Invalidates the SCARDCONTEXT handle the listener is actively polling against. + /// This is exactly what happens when a Windows RDS session is disconnected: + /// the Smart Card Service invalidates all existing context handles for that session. + /// + private static void InvalidateListenerContext(SmartCardDeviceListener listener) + { + SCardContext context = GetListenerContext(listener); + // SCardReleaseContext with the raw IntPtr tells WinSCard the handle is gone. + // Subsequent SCardGetStatusChange calls using this handle will fail immediately + // with SCARD_E_INVALID_HANDLE and trigger WinSCard's internal C++ exception path. + uint result = NativeMethods.SCardReleaseContext(context.DangerousGetHandle()); + Skip.If(result != ErrorCode.SCARD_S_SUCCESS, + $"SCardReleaseContext failed with 0x{result:X8}; context may already be invalid or disposed. Skipping test."); + } + + /// + /// Returns true if the listener successfully established a Smart Card context. + /// If SCardSvr is not running, the listener enters Error/dormant status and + /// the tests should be skipped rather than fail. + /// + private static bool ListenerIsActive(SmartCardDeviceListener listener) => + listener.Status == DeviceListenerStatus.Started; + + // ───────────────────────────────────────────────────────────────────────────────────── + // Test 1: CPU measurement — the gold standard for issue #434 + // + // This test FAILS before the fix is applied and PASSES after. + // + // Before fix: SCARD_E_INVALID_HANDLE unhandled → loop spins at thousands/sec → + // each spin calls WinSCard with invalid handle → WinSCard raises C++ exception + // internally → CxxThrowException / RtlUnwindEx machinery runs → CPU pegged. + // TotalProcessorTime over 3s: > 2000ms (one core pegged). + // + // After fix: SCARD_E_INVALID_HANDLE handled → UpdateCurrentContext() called → + // Thread.Sleep(1000) back-off applied → ~1 call/sec → CxxThrowException fires + // at most once per second → negligible CPU. + // TotalProcessorTime over 3s: < 500ms. + // ───────────────────────────────────────────────────────────────────────────────────── + + [SkippableFact] + [Trait("Category", "WindowsOnly")] + [Trait("Category", "CpuRegression")] + public void RealWinSCard_WhenHandleInvalidated_CpuDoesNotSpike() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "This test requires WinSCard.dll and is only valid on Windows."); + + using var listener = SmartCardDeviceListener.Create(); + + Skip.IfNot(ListenerIsActive(listener), + "Smart Card service (SCardSvr) is not running on this machine. " + + "Enable the service and re-run."); + + // Let the listener settle into its normal 100ms polling cadence. + Thread.Sleep(300); + + // Invalidate the handle — simulates RDS session disconnect. + InvalidateListenerContext(listener); + + // Measure CPU consumption over the observation window. + // The process should be otherwise idle during this window. + var cpuBefore = Process.GetCurrentProcess().TotalProcessorTime; + const int observationWindowMs = 3000; + Thread.Sleep(observationWindowMs); + var cpuAfter = Process.GetCurrentProcess().TotalProcessorTime; + + var cpuConsumedMs = (cpuAfter - cpuBefore).TotalMilliseconds; + + // Threshold: 1500ms CPU in 3000ms wall-clock. + // With fix: 1 retry/sec × (cheap EstablishContext + 1000ms sleep) ≈ 30–100ms + // Without fix: core pegged ≈ 2500–3000ms + // Headroom: raised from 500ms to 1500ms to tolerate concurrent test activity + // in CI while still clearly catching the unfixed behavior (≥2500ms). + Assert.True( + cpuConsumedMs < 1500, + $"CPU consumed {cpuConsumedMs:F0}ms in {observationWindowMs}ms wall-clock after " + + "handle invalidation. Expected < 1500ms. " + + "This is the high-CPU symptom from GitHub issue #434: " + + "WinSCard raises a C++ exception (CxxThrowException) for every call " + + "made with an invalid SCARDCONTEXT handle. " + + "The fix must add a backoff after SCARD_E_INVALID_HANDLE to reduce the call rate."); + } + + // ───────────────────────────────────────────────────────────────────────────────────── + // Test 2: Recovery — context re-establishment with real WinSCard + // + // After invalidating the handle, the listener must re-establish a fresh SCARDCONTEXT. + // Verifies that the new handle is different from (and valid, unlike) the old one. + // This test is complementary to the CPU test: it proves the listener recovers + // functionally, not just stops spinning. + // ───────────────────────────────────────────────────────────────────────────────────── + + [SkippableFact] + [Trait("Category", "WindowsOnly")] + public void RealWinSCard_WhenHandleInvalidated_NewContextIsEstablished() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "This test requires WinSCard.dll and is only valid on Windows."); + + using var listener = SmartCardDeviceListener.Create(); + + Skip.IfNot(ListenerIsActive(listener), + "Smart Card service (SCardSvr) is not running on this machine."); + + Thread.Sleep(300); + + // Invalidate. + InvalidateListenerContext(listener); + + // Give the listener time to detect SCARD_E_INVALID_HANDLE, call + // UpdateCurrentContext (EstablishContext), sleep 1000ms, and continue. + Thread.Sleep(2500); + + // The listener should have replaced _context with a new valid handle. + SCardContext newContext = GetListenerContext(listener); + + Assert.False( + newContext.IsInvalid, + "The new SCARDCONTEXT handle is invalid. " + + "UpdateCurrentContext must have called SCardEstablishContext and stored the result."); + + Assert.False( + newContext.IsClosed, + "The new SCARDCONTEXT handle is closed. " + + "UpdateCurrentContext should store a live handle."); + + // Listener must still be polling normally — not in Error state. + Assert.Equal(DeviceListenerStatus.Started, listener.Status); + } + + // ───────────────────────────────────────────────────────────────────────────────────── + // Test 3: Dispose safety after handle invalidation + // + // After the handle is invalidated and the recovery path fires, Dispose must still + // complete cleanly within a reasonable time (SCardCancel on the new context, + // StopListening, context.Dispose). Regression guard: this was a secondary risk + // identified in the Opus Engineer review (thread safety race with _context replacement). + // ───────────────────────────────────────────────────────────────────────────────────── + + [SkippableFact] + [Trait("Category", "WindowsOnly")] + public void RealWinSCard_WhenHandleInvalidatedThenDisposed_DisposalCompletesCleanly() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "This test requires WinSCard.dll and is only valid on Windows."); + + var listener = SmartCardDeviceListener.Create(); + + try + { + Skip.IfNot(ListenerIsActive(listener), + "Smart Card service (SCardSvr) is not running on this machine."); + + Thread.Sleep(300); + InvalidateListenerContext(listener); + + // Let recovery fire once (1000ms sleep inside the listener thread). + Thread.Sleep(1500); + + // Now dispose — must complete well within 8 seconds. + var stopwatch = Stopwatch.StartNew(); + var exception = Record.Exception(() => listener.Dispose()); + stopwatch.Stop(); + + Assert.Null(exception); + Assert.True( + stopwatch.ElapsedMilliseconds < 5000, + $"Dispose took {stopwatch.ElapsedMilliseconds}ms after handle invalidation. " + + "Expected < 5000ms. The listener thread may be blocked in the recovery sleep."); + } + finally + { + listener.Dispose(); + } + } + } +} diff --git a/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensionsTests.cs b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensionsTests.cs new file mode 100644 index 000000000..24b0cd67b --- /dev/null +++ b/Yubico.Core/tests/Yubico/Core/Devices/SmartCard/SmartCardLoggerExtensionsTests.cs @@ -0,0 +1,150 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Devices.SmartCard.UnitTests +{ + public class SmartCardLoggerExtensionsTests + { + // ----------------------------------------------------------------------------------------- + // Step 6: Verify that knownRecoverable flag downgrades failure logs to Debug + // ----------------------------------------------------------------------------------------- + + [Fact] + public void SCardApiCall_WhenKnownRecoverableTrue_AndNonSuccess_LogsAtDebug() + { + // Arrange + var fakeLogger = new FakeLogger(); + const string apiName = "SCardEstablishContext"; + const uint errorCode = ErrorCode.SCARD_E_INVALID_HANDLE; + + // Act + fakeLogger.SCardApiCall(apiName, errorCode, knownRecoverable: true); + + // Assert + Assert.Single(fakeLogger.LogEntries); + LogEntry entry = fakeLogger.LogEntries[0]; + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Contains(apiName, entry.Message); + Assert.Contains("FAILED", entry.Message); + Assert.Contains("known recoverable", entry.Message); + } + + [Fact] + public void SCardApiCall_WhenKnownRecoverableFalse_AndNonSuccess_LogsAtError() + { + // Arrange + var fakeLogger = new FakeLogger(); + const string apiName = "SCardEstablishContext"; + const uint errorCode = ErrorCode.SCARD_E_INVALID_HANDLE; + + // Act + fakeLogger.SCardApiCall(apiName, errorCode, knownRecoverable: false); + + // Assert + Assert.Single(fakeLogger.LogEntries); + LogEntry entry = fakeLogger.LogEntries[0]; + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Contains(apiName, entry.Message); + Assert.Contains("FAILED", entry.Message); + } + + [Fact] + public void SCardApiCall_WhenSuccess_LogsAtInformationRegardlessOfKnownRecoverable() + { + // Arrange + var fakeLogger = new FakeLogger(); + const string apiName = "SCardEstablishContext"; + const uint successCode = ErrorCode.SCARD_S_SUCCESS; + + // Act - knownRecoverable: true + fakeLogger.SCardApiCall(apiName, successCode, knownRecoverable: true); + + // Assert + Assert.Single(fakeLogger.LogEntries); + LogEntry entry = fakeLogger.LogEntries[0]; + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("successfully", entry.Message); + + // Reset and test knownRecoverable: false + fakeLogger.LogEntries.Clear(); + fakeLogger.SCardApiCall(apiName, successCode, knownRecoverable: false); + + Assert.Single(fakeLogger.LogEntries); + entry = fakeLogger.LogEntries[0]; + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("successfully", entry.Message); + } + + [Fact] + public void SCardApiCall_OriginalOverload_AndNonSuccess_LogsAtError() + { + // Arrange + var fakeLogger = new FakeLogger(); + const string apiName = "SCardEstablishContext"; + const uint errorCode = ErrorCode.SCARD_E_INVALID_HANDLE; + + // Act - original single-argument overload + fakeLogger.SCardApiCall(apiName, errorCode); + + // Assert + Assert.Single(fakeLogger.LogEntries); + LogEntry entry = fakeLogger.LogEntries[0]; + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Contains(apiName, entry.Message); + Assert.Contains("FAILED", entry.Message); + } + + // ----------------------------------------------------------------------------------------- + // FakeLogger — Minimal ILogger implementation that captures log calls + // ----------------------------------------------------------------------------------------- + + private sealed class FakeLogger : ILogger + { + public List LogEntries { get; } = new List(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + string message = formatter(state, exception); + LogEntries.Add(new LogEntry(logLevel, message)); + } + } + + private sealed class LogEntry + { + public LogLevel Level { get; } + public string Message { get; } + + public LogEntry(LogLevel level, string message) + { + Level = level; + Message = message; + } + } + } +} diff --git a/Yubico.Core/tests/Yubico/Core/Tlv/TlvObjectTests.cs b/Yubico.Core/tests/Yubico/Core/Tlv/TlvObjectTests.cs index f62dcc348..b56411014 100644 --- a/Yubico.Core/tests/Yubico/Core/Tlv/TlvObjectTests.cs +++ b/Yubico.Core/tests/Yubico/Core/Tlv/TlvObjectTests.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Yubico AB +// Copyright 2026 Yubico AB // // Licensed under the Apache License, Version 2.0 (the "License"). // You may not use this file except in compliance with the License. @@ -185,5 +185,30 @@ public void UnpackValue_EmptyValue_ReturnsEmptyArray() var result = TlvObjects.UnpackValue(0x01, input); Assert.Empty(result.ToArray()); } + + [Fact] + public void Parse_IndefiniteLengthByte_ThrowsTlvException() + { + // Tag 0x01 followed by length byte 0x80 (BER-TLV indefinite length form). + // TlvObject only supports determinate lengths, so this must be rejected. + var input = new byte[] { 0x01, 0x80, 0x00, 0x00 }; + Assert.Throws(() => TlvObject.Parse(input)); + } + + [Fact] + public void Parse_MultiByteLengthTooManyOctets_ThrowsTlvException() + { + // Tag 0x01 followed by 0x85 (5 length octets), which exceeds the 4-octet cap. + var input = new byte[] { 0x01, 0x85, 0x00, 0x00, 0x00, 0x00, 0x00 }; + Assert.Throws(() => TlvObject.Parse(input)); + } + + [Fact] + public void Parse_TruncatedValue_ThrowsTlvException() + { + // Tag 0x01, length 0x05, but only 2 bytes of value data provided. + var input = new byte[] { 0x01, 0x05, 0xAA, 0xBB }; + Assert.Throws(() => TlvObject.Parse(input)); + } } } diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs similarity index 98% rename from Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs rename to Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs index 12586c3aa..6eae91405 100644 --- a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs +++ b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs @@ -16,8 +16,9 @@ using System.Linq; using System.Security.Cryptography; using Xunit; +using Yubico.Core.Cryptography; -namespace Yubico.YubiKey.Cryptography; +namespace Yubico.Core.Cryptography; public class HkdfUtilitiesTests { [Fact] diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs new file mode 100644 index 000000000..e5b741bb0 --- /dev/null +++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs @@ -0,0 +1,184 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Purpose +// ------- +// Direct P/Invoke functional tests for the OpenSSL BIGNUM marshaling layer +// exposed by Yubico.NativeShims (Native_BN_*). These wrappers move arbitrary- +// precision integers across the C#/C boundary; subtle bugs in length handling, +// padding, or leading-zero behavior surface as silent corruption in EC point +// coordinates and ARKG primitives that build on top. +// +// What this validates +// ------------------- +// * bin -> BIGNUM -> bin round-trip preserves bytes for sizes 1, 16, 32, 256. +// * Native_BN_num_bytes returns the canonical length (leading zeros stripped, +// matching OpenSSL semantics). +// * Native_BN_bn2binpad left-pads to a fixed width without truncating. +// * Lifecycle: Native_BN_new, Native_BN_clear_free do not crash or leak under +// repeated allocate/free. +// +// References +// ---------- +// * OpenSSL BN(3) man page — https://docs.openssl.org/master/man3/BN_new/ +// (authoritative for BN_new, BN_bin2bn, BN_bn2bin, BN_bn2binpad, +// BN_num_bytes, BN_clear_free behavior). +// * No formal standards-track spec exists for the BIGNUM API; round-trip +// and boundary tests are self-consistent against the OpenSSL contract. + +using System; +using System.Linq; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.PlatformInterop.Cryptography +{ + public class BigNumInteropTests + { + [Fact] + public void BnBinaryToBigNum_RoundTrip_SingleByte_ReturnsOriginal() + { + byte[] original = { 0x42 }; + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[1]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + Assert.Equal(1, written); + Assert.Equal(original, buffer); + } + + [Fact] + public void BnBinaryToBigNum_RoundTrip_16Bytes_ReturnsOriginal() + { + byte[] original = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[16]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + Assert.Equal(16, written); + Assert.Equal(original, buffer); + } + + [Fact] + public void BnBinaryToBigNum_RoundTrip_32Bytes_ReturnsOriginal() + { + byte[] original = Enumerable.Range(0, 32).Select(i => (byte)((i * 7) + 13)).ToArray(); + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[32]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + Assert.Equal(32, written); + Assert.Equal(original, buffer); + } + + [Fact] + public void BnBinaryToBigNum_RoundTrip_256Bytes_ReturnsOriginal() + { + // Start with 0x01 to avoid leading-zero stripping + byte[] original = Enumerable.Range(1, 256).Select(i => (byte)i).ToArray(); + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[256]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + Assert.Equal(256, written); + Assert.Equal(original, buffer); + } + + [Fact] + public void BnBinaryToBigNum_LeadingZero_StripsLeadingZeros() + { + // OpenSSL BIGNUMs strip leading zeros + byte[] original = { 0x00, 0x00, 0x01, 0x23, 0x45 }; + byte[] expected = { 0x01, 0x23, 0x45 }; + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[5]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + Assert.Equal(3, written); + Assert.Equal(expected, buffer.Take(written).ToArray()); + } + + [Fact] + public void BnBinaryToBigNum_AllZeros_HandlesGracefully() + { + byte[] original = { 0x00, 0x00, 0x00 }; + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[3]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + // All zeros represents the number 0, which OpenSSL represents as zero bytes + Assert.Equal(0, written); + } + + [Fact] + public void BnBigNumToBinaryWithPadding_PadsTo32Bytes() + { + byte[] original = { 0x12, 0x34 }; + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[32]; + int written = NativeMethods.BnBigNumToBinaryWithPadding(bn, buffer); + + Assert.Equal(32, written); + // Padding should be zero-bytes on the left (big-endian) + byte[] expected = new byte[32]; + expected[30] = 0x12; + expected[31] = 0x34; + Assert.Equal(expected, buffer); + } + + [Fact] + public void BnBigNumToBinaryWithPadding_PadsTo16Bytes() + { + byte[] original = { 0xAB }; + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[16]; + int written = NativeMethods.BnBigNumToBinaryWithPadding(bn, buffer); + + Assert.Equal(16, written); + byte[] expected = new byte[16]; + expected[15] = 0xAB; + Assert.Equal(expected, buffer); + } + + [Fact] + public void BnNew_CreatesValidHandle() + { + using SafeBigNum bn = NativeMethods.BnNew(); + + Assert.NotNull(bn); + Assert.False(bn.IsInvalid); + } + + [Fact] + public void BnBinaryToBigNum_EmptyArray_HandlesGracefully() + { + byte[] original = Array.Empty(); + + using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original); + byte[] buffer = new byte[16]; + int written = NativeMethods.BnBigNumToBinary(bn, buffer); + + // Empty input = zero + Assert.Equal(0, written); + } + } +} diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs new file mode 100644 index 000000000..d98e0164b --- /dev/null +++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs @@ -0,0 +1,271 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Purpose +// ------- +// Direct P/Invoke functional tests for the AES-128-CMAC (Cipher-based MAC) +// EVP MAC wrappers exposed by Yubico.NativeShims (Native_CMAC_EVP_MAC_*). +// CMAC is consumed by SCP03 / PIV / OATH session authentication paths; an +// off-by-one in update chunking or a wrong subkey derivation results in +// silent authentication failures against real YubiKeys. +// +// What this validates +// ------------------- +// * MAC context lifecycle: Native_CMAC_EVP_MAC_CTX_new / Native_EVP_MAC_CTX_free. +// * Native_CMAC_EVP_MAC_init binds the AES-128 key. +// * Native_CMAC_EVP_MAC_update accepts variable-length chunks. +// * Native_CMAC_EVP_MAC_final emits the 16-byte tag. +// * RFC 4493 §4 published test vectors (AES-128) for messages of length +// 0, 16, 40, and 64 bytes — pins the wire-level contract. +// * Multi-update equivalence: update(A) followed by update(B) produces the +// same tag as update(A || B). Catches buffer-management regressions in +// the C side. +// +// References +// ---------- +// * RFC 4493 — The AES-CMAC Algorithm, §2 (Specification), §4 (Test Vectors). +// https://datatracker.ietf.org/doc/html/rfc4493 +// * NIST SP 800-38B — Recommendation for Block Cipher Modes of Operation: +// The CMAC Mode for Authentication. +// https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38B.pdf +// * FIPS 197 — Advanced Encryption Standard (AES). +// https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf +// * OpenSSL EVP_MAC(3) — https://docs.openssl.org/master/man3/EVP_MAC/ + +using System; +using System.Linq; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.PlatformInterop.Cryptography +{ + public class CmacInteropTests + { + // Algorithm constants (from Cmac.Interop.cs comment) + private const int Aes128Cbc = 1; + private const int Aes192Cbc = 2; + private const int Aes256Cbc = 3; + + // RFC 4493 §4 test vectors for AES-128-CMAC + // Reference: https://www.rfc-editor.org/rfc/rfc4493.html#section-4 + private static readonly byte[] RFC4493_Key = new byte[] + { + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, + 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, + }; + + // Example 1: Empty message + private static readonly byte[] RFC4493_Example1_Message = Array.Empty(); + private static readonly byte[] RFC4493_Example1_MAC = new byte[] + { + 0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, + 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75, 0x67, 0x46, + }; + + // Example 2: 16-byte message + private static readonly byte[] RFC4493_Example2_Message = new byte[] + { + 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, + 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a, + }; + private static readonly byte[] RFC4493_Example2_MAC = new byte[] + { + 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, + 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, 0x28, 0x7c, + }; + + // Example 3: 40-byte message + private static readonly byte[] RFC4493_Example3_Message = new byte[] + { + 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, + 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a, + 0xae, 0x2d, 0x8a, 0x57, 0x1e, 0x03, 0xac, 0x9c, + 0x9e, 0xb7, 0x6f, 0xac, 0x45, 0xaf, 0x8e, 0x51, + 0x30, 0xc8, 0x1c, 0x46, 0xa3, 0x5c, 0xe4, 0x11, + }; + private static readonly byte[] RFC4493_Example3_MAC = new byte[] + { + 0xdf, 0xa6, 0x67, 0x47, 0xde, 0x9a, 0xe6, 0x30, + 0x30, 0xca, 0x32, 0x61, 0x14, 0x97, 0xc8, 0x27, + }; + + // Example 4: 64-byte message + private static readonly byte[] RFC4493_Example4_Message = new byte[] + { + 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, + 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a, + 0xae, 0x2d, 0x8a, 0x57, 0x1e, 0x03, 0xac, 0x9c, + 0x9e, 0xb7, 0x6f, 0xac, 0x45, 0xaf, 0x8e, 0x51, + 0x30, 0xc8, 0x1c, 0x46, 0xa3, 0x5c, 0xe4, 0x11, + 0xe5, 0xfb, 0xc1, 0x19, 0x1a, 0x0a, 0x52, 0xef, + 0xf6, 0x9f, 0x24, 0x45, 0xdf, 0x4f, 0x9b, 0x17, + 0xad, 0x2b, 0x41, 0x7b, 0xe6, 0x6c, 0x37, 0x10, + }; + private static readonly byte[] RFC4493_Example4_MAC = new byte[] + { + 0x51, 0xf0, 0xbe, 0xbf, 0x7e, 0x3b, 0x9d, 0x92, + 0xfc, 0x49, 0x74, 0x17, 0x79, 0x36, 0x3c, 0xfe, + }; + + [Fact] + public void CmacEvpMacCtxNew_CreatesValidContext() + { + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + Assert.NotNull(ctx); + Assert.False(ctx.IsInvalid); + } + + [Fact] + public void CmacEvpMacInit_ValidParameters_ReturnsSuccess() + { + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + int result = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + + Assert.Equal(1, result); + } + + [Fact] + public void Cmac_RFC4493_Example1_EmptyMessage_MatchesExpectedMAC() + { + // RFC 4493 §4 Example 1: empty message + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + Assert.Equal(1, initResult); + + byte[] mac = new byte[16]; + int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen); + Assert.Equal(1, finalResult); + Assert.Equal(16, macLen); + + Assert.Equal(RFC4493_Example1_MAC, mac); + } + + [Fact] + public void Cmac_RFC4493_Example2_16ByteMessage_MatchesExpectedMAC() + { + // RFC 4493 §4 Example 2: 16-byte message (one block) + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + Assert.Equal(1, initResult); + + int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example2_Message, RFC4493_Example2_Message.Length); + Assert.Equal(1, updateResult); + + byte[] mac = new byte[16]; + int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen); + Assert.Equal(1, finalResult); + Assert.Equal(16, macLen); + + Assert.Equal(RFC4493_Example2_MAC, mac); + } + + [Fact] + public void Cmac_RFC4493_Example3_40ByteMessage_MatchesExpectedMAC() + { + // RFC 4493 §4 Example 3: 40-byte message (non-block-aligned) + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + Assert.Equal(1, initResult); + + int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example3_Message, RFC4493_Example3_Message.Length); + Assert.Equal(1, updateResult); + + byte[] mac = new byte[16]; + int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen); + Assert.Equal(1, finalResult); + Assert.Equal(16, macLen); + + Assert.Equal(RFC4493_Example3_MAC, mac); + } + + [Fact] + public void Cmac_RFC4493_Example4_64ByteMessage_MatchesExpectedMAC() + { + // RFC 4493 §4 Example 4: 64-byte message (block-aligned, multiple blocks) + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + Assert.Equal(1, initResult); + + int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example4_Message, RFC4493_Example4_Message.Length); + Assert.Equal(1, updateResult); + + byte[] mac = new byte[16]; + int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen); + Assert.Equal(1, finalResult); + Assert.Equal(16, macLen); + + Assert.Equal(RFC4493_Example4_MAC, mac); + } + + [Fact] + public void Cmac_MultiUpdate_EquivalentToSingleUpdate() + { + // update(A) + update(B) should equal update(A||B) + byte[] messageA = RFC4493_Example4_Message.Take(32).ToArray(); + byte[] messageB = RFC4493_Example4_Message.Skip(32).ToArray(); + + // Single update + byte[] macSingle; + using (SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew()) + { + NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example4_Message, RFC4493_Example4_Message.Length); + macSingle = new byte[16]; + NativeMethods.CmacEvpMacFinal(ctx, macSingle, macSingle.Length, out _); + } + + // Multi update + byte[] macMulti; + using (SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew()) + { + NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + NativeMethods.CmacEvpMacUpdate(ctx, messageA, messageA.Length); + NativeMethods.CmacEvpMacUpdate(ctx, messageB, messageB.Length); + macMulti = new byte[16]; + NativeMethods.CmacEvpMacFinal(ctx, macMulti, macMulti.Length, out _); + } + + Assert.Equal(macSingle, macMulti); + Assert.Equal(RFC4493_Example4_MAC, macMulti); + } + + [Fact] + public void Cmac_MultiUpdate_ThreeChunks_MatchesRFC() + { + // Verify multi-update with three arbitrary chunks of Example 3 (40 bytes) + byte[] chunk1 = RFC4493_Example3_Message.Take(10).ToArray(); + byte[] chunk2 = RFC4493_Example3_Message.Skip(10).Take(20).ToArray(); + byte[] chunk3 = RFC4493_Example3_Message.Skip(30).ToArray(); + + using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew(); + + NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length); + NativeMethods.CmacEvpMacUpdate(ctx, chunk1, chunk1.Length); + NativeMethods.CmacEvpMacUpdate(ctx, chunk2, chunk2.Length); + NativeMethods.CmacEvpMacUpdate(ctx, chunk3, chunk3.Length); + + byte[] mac = new byte[16]; + NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen); + + Assert.Equal(16, macLen); + Assert.Equal(RFC4493_Example3_MAC, mac); + } + } +} diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs new file mode 100644 index 000000000..aa443f420 --- /dev/null +++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs @@ -0,0 +1,251 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Purpose +// ------- +// Direct P/Invoke functional tests for the OpenSSL EC group / EC point +// marshaling layer exposed by Yubico.NativeShims (Native_EC_GROUP_*, +// Native_EC_POINT_*). These wrappers underpin every ECC operation in the SDK +// (ECDH, ARKG-P256 on-curve validation, FIDO2 key handling); marshaling +// regressions cascade silently into wrong shared secrets or accepted +// invalid-curve points. +// +// What this validates +// ------------------- +// * Group/point lifecycle on NIST P-256 (curve NID 415). +// * Native_EC_POINT_set_affine_coordinates + get_affine_coordinates +// round-trips the SEC2 P-256 generator G unchanged. +// * Native_EC_POINT_mul: G·1 = G; G·n (n = group order) = point at infinity +// (get_affine subsequently fails as expected). +// * Native_EC_POINT_is_on_curve: returns 1 for the valid generator, +// 0 for a Y-bit-flipped off-curve candidate. Required for SEC 1 §3.2.2 +// public-key validation in ARKG-P256. +// +// References +// ---------- +// * SEC 2: Recommended Elliptic Curve Domain Parameters, v2.0 §2.4.2 +// (secp256r1 / NIST P-256 generator and group order) +// https://www.secg.org/sec2-v2.pdf +// * SEC 1: Elliptic Curve Cryptography, v2.0 §3.2.2 (Public Key Validation) +// https://www.secg.org/sec1-v2.pdf +// * NIST SP 800-186 §3.2.1.3 (Curve P-256) — current authoritative source +// for NIST P-256 domain parameters (the FIPS 186-5 revision moved curve +// definitions out of FIPS 186 into SP 800-186). +// https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-186.pdf +// * OpenSSL EC_POINT(3) man page — +// https://docs.openssl.org/master/man3/EC_POINT_new/ + +using System; +using Xunit; +using Yubico.PlatformInterop; + +namespace Yubico.PlatformInterop.Cryptography +{ + public class EcPointInteropTests + { + // P-256 curve NID (OpenSSL constant for X9.62 prime256v1) + private const int NidP256 = 415; + + // P-256 generator G (SEC1 uncompressed: 0x04 || Gx || Gy). + // Reference: SEC2 v2 §2.4.2. + private static readonly byte[] P256GeneratorX = + { + 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, + 0xF8, 0xBC, 0xE6, 0xE5, 0x63, 0xA4, 0x40, 0xF2, + 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0, + 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, + }; + + private static readonly byte[] P256GeneratorY = + { + 0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, + 0x8E, 0xE7, 0xEB, 0x4A, 0x7C, 0x0F, 0x9E, 0x16, + 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE, + 0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5, + }; + + // P-256 group order (SEC2 v2 §2.4.2) + private static readonly byte[] P256Order = + { + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84, + 0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51, + }; + + [Fact] + public void EcGroupNewByCurveName_P256_CreatesValidGroup() + { + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + + Assert.NotNull(group); + Assert.False(group.IsInvalid); + } + + [Fact] + public void EcPointNew_ValidGroup_CreatesValidPoint() + { + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint point = NativeMethods.EcPointNew(group); + + Assert.NotNull(point); + Assert.False(point.IsInvalid); + } + + [Fact] + public void EcPointIsOnCurve_P256Generator_ReturnsTrue() + { + // SEC2 §2.4.2 P-256 generator G is on the curve + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint point = NativeMethods.EcPointNew(group); + using SafeBigNum x = NativeMethods.BnBinaryToBigNum(P256GeneratorX); + using SafeBigNum y = NativeMethods.BnBinaryToBigNum(P256GeneratorY); + + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, x, y); + Assert.Equal(1, setResult); + + int isOnCurve = NativeMethods.EcPointIsOnCurve(group, point); + Assert.Equal(1, isOnCurve); + } + + [Fact] + public void EcPointIsOnCurve_OffCurvePoint_ReturnsFalse() + { + // Flip the lowest bit of Y to create a point not on the curve + byte[] offCurveY = (byte[])P256GeneratorY.Clone(); + offCurveY[31] ^= 0x01; + + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint point = NativeMethods.EcPointNew(group); + using SafeBigNum x = NativeMethods.BnBinaryToBigNum(P256GeneratorX); + using SafeBigNum y = NativeMethods.BnBinaryToBigNum(offCurveY); + + // set_affine_coordinates might fail for invalid points, but if it succeeds, + // is_on_curve must return 0 + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, x, y); + if (setResult == 1) + { + int isOnCurve = NativeMethods.EcPointIsOnCurve(group, point); + Assert.Equal(0, isOnCurve); + } + // If set fails, the point is invalid - that's also acceptable behavior + } + + [Fact] + public void EcPointGetAffineCoordinates_RoundTrip_MatchesOriginal() + { + // G·1 round-trips back to G via set/get affine coordinates + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint point = NativeMethods.EcPointNew(group); + using SafeBigNum xIn = NativeMethods.BnBinaryToBigNum(P256GeneratorX); + using SafeBigNum yIn = NativeMethods.BnBinaryToBigNum(P256GeneratorY); + + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, xIn, yIn); + Assert.Equal(1, setResult); + + using SafeBigNum xOut = NativeMethods.BnNew(); + using SafeBigNum yOut = NativeMethods.BnNew(); + + int getResult = NativeMethods.EcPointGetAffineCoordinates(group, point, xOut, yOut); + Assert.Equal(1, getResult); + + byte[] xBytes = new byte[32]; + byte[] yBytes = new byte[32]; + int xLen = NativeMethods.BnBigNumToBinaryWithPadding(xOut, xBytes); + int yLen = NativeMethods.BnBigNumToBinaryWithPadding(yOut, yBytes); + + Assert.Equal(32, xLen); + Assert.Equal(32, yLen); + Assert.Equal(P256GeneratorX, xBytes); + Assert.Equal(P256GeneratorY, yBytes); + } + + [Fact] + public void EcPointMul_GeneratorTimesOne_ReturnsGenerator() + { + // G·1 = G + byte[] scalarOne = new byte[32]; + scalarOne[31] = 1; + + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint generatorPoint = NativeMethods.EcPointNew(group); + using SafeBigNum xGen = NativeMethods.BnBinaryToBigNum(P256GeneratorX); + using SafeBigNum yGen = NativeMethods.BnBinaryToBigNum(P256GeneratorY); + + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, generatorPoint, xGen, yGen); + Assert.Equal(1, setResult); + + using SafeBigNum scalarBn = NativeMethods.BnBinaryToBigNum(scalarOne); + using SafeEcPoint result = NativeMethods.EcPointNew(group); + + // EC_POINT_mul(group, r, n, q, m, ctx) computes r = n·G + m·q + // To compute q·scalar, pass n=0, q=generatorPoint, m=scalar + int mulResult = NativeMethods.EcPointMul( + group, + result, + IntPtr.Zero, // n = NULL (don't add generator multiple) + generatorPoint.DangerousGetHandle(), // q + scalarBn.DangerousGetHandle()); // m + + Assert.Equal(1, mulResult); + + using SafeBigNum xResult = NativeMethods.BnNew(); + using SafeBigNum yResult = NativeMethods.BnNew(); + + int getResult = NativeMethods.EcPointGetAffineCoordinates(group, result, xResult, yResult); + Assert.Equal(1, getResult); + + byte[] xBytes = new byte[32]; + byte[] yBytes = new byte[32]; + NativeMethods.BnBigNumToBinaryWithPadding(xResult, xBytes); + NativeMethods.BnBigNumToBinaryWithPadding(yResult, yBytes); + + Assert.Equal(P256GeneratorX, xBytes); + Assert.Equal(P256GeneratorY, yBytes); + } + + [Fact] + public void EcPointMul_GeneratorTimesOrder_ReturnsPointAtInfinity() + { + // G·n where n = P-256 group order → point at infinity + // Point at infinity cannot have affine coordinates extracted + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256); + using SafeEcPoint generatorPoint = NativeMethods.EcPointNew(group); + using SafeBigNum xGen = NativeMethods.BnBinaryToBigNum(P256GeneratorX); + using SafeBigNum yGen = NativeMethods.BnBinaryToBigNum(P256GeneratorY); + + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, generatorPoint, xGen, yGen); + Assert.Equal(1, setResult); + + using SafeBigNum orderBn = NativeMethods.BnBinaryToBigNum(P256Order); + using SafeEcPoint result = NativeMethods.EcPointNew(group); + + int mulResult = NativeMethods.EcPointMul( + group, + result, + IntPtr.Zero, + generatorPoint.DangerousGetHandle(), + orderBn.DangerousGetHandle()); + + Assert.Equal(1, mulResult); + + using SafeBigNum xResult = NativeMethods.BnNew(); + using SafeBigNum yResult = NativeMethods.BnNew(); + + // Attempting to get affine coordinates of point at infinity should fail + int getResult = NativeMethods.EcPointGetAffineCoordinates(group, result, xResult, yResult); + Assert.Equal(0, getResult); + } + } +} diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs new file mode 100644 index 000000000..c8ece1c17 --- /dev/null +++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs @@ -0,0 +1,337 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Purpose +// ------- +// Direct P/Invoke functional tests for the AES-256-GCM (Galois/Counter Mode) +// EVP cipher wrappers exposed by Yubico.NativeShims (Native_EVP_*). GCM is an +// authenticated cipher: tag verification is the only thing standing between a +// caller and accepting tampered ciphertext, so these tests must validate not +// just round-trips but also that tag mismatch causes Final_ex to return 0 +// (decryption rejected). +// +// What this validates +// ------------------- +// * Cipher context lifecycle: Native_EVP_CIPHER_CTX_new / _free. +// * Native_EVP_Aes256Gcm_Init for both encrypt and decrypt direction. +// * Native_EVP_Update for AAD (output=null) and plaintext / ciphertext. +// * Native_EVP_Final_ex computes / verifies the tag. +// * Native_EVP_CIPHER_CTX_ctrl with EVP_CTRL_AEAD_GET_TAG (16) / +// EVP_CTRL_AEAD_SET_TAG (17) — note: numeric values shared between C# and +// C without a header, so this test pins the contract. +// * NIST SP 800-38D Test Cases (256-bit key) — encrypt/decrypt against +// known answers. +// * AAD round-trip and tag tamper detection (single-bit flip causes +// Final_ex == 0 on decrypt). +// +// References +// ---------- +// * NIST SP 800-38D — Recommendation for Block Cipher Modes of Operation: +// Galois/Counter Mode (GCM) and GMAC, §7.1 (Authenticated Encryption), +// §7.2 (Authenticated Decryption). +// https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf +// * NIST GCM Test Vectors (CAVP) — +// https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/cavp-testing-block-cipher-modes +// * FIPS 197 — Advanced Encryption Standard (AES). +// https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf +// * OpenSSL EVP_EncryptInit(3) — https://docs.openssl.org/master/man3/EVP_EncryptInit/ + +using System; +using System.Linq; +using Xunit; +using Yubico.PlatformInterop; +using static Yubico.PlatformInterop.NativeMethods; + +namespace Yubico.PlatformInterop.Cryptography +{ + public class GcmEvpInteropTests + { + // NIST SP 800-38D Test Case 13: 256-bit key, empty plaintext, empty AAD + // Reference: https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Algorithm-Validation-Program/documents/mac/gcmtestvectors.zip + private static readonly byte[] Nist_TC13_Key = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + private static readonly byte[] Nist_TC13_Nonce = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }; + + private static readonly byte[] Nist_TC13_Tag = new byte[] + { + 0x53, 0x0F, 0x8A, 0xFB, 0xC7, 0x45, 0x36, 0xB9, + 0xA9, 0x63, 0xB4, 0xF1, 0xC4, 0xCB, 0x73, 0x8B, + }; + + // NIST SP 800-38D Test Case 14: 256-bit key, 16-byte plaintext, empty AAD + private static readonly byte[] Nist_TC14_Key = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + private static readonly byte[] Nist_TC14_Nonce = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }; + + private static readonly byte[] Nist_TC14_Plaintext = new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + private static readonly byte[] Nist_TC14_Ciphertext = new byte[] + { + 0xCE, 0xA7, 0x40, 0x3D, 0x4D, 0x60, 0x6B, 0x6E, + 0x07, 0x4E, 0xC5, 0xD3, 0xBA, 0xF3, 0x9D, 0x18, + }; + + private static readonly byte[] Nist_TC14_Tag = new byte[] + { + 0xD0, 0xD1, 0xC8, 0xA7, 0x99, 0x99, 0x6B, 0xF0, + 0x26, 0x5B, 0x98, 0xB5, 0xD4, 0x8A, 0xB9, 0x19, + }; + + [Fact] + public void EvpCipherCtxNew_CreatesValidContext() + { + using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew(); + + Assert.NotNull(ctx); + Assert.False(ctx.IsInvalid); + } + + [Fact] + public void EvpAes256GcmInit_ValidParameters_ReturnsSuccess() + { + using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew(); + + int result = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC13_Key, Nist_TC13_Nonce); + + Assert.Equal(1, result); + } + + [Fact] + public void Encrypt_EmptyPlaintext_MatchesNistTC13Tag() + { + // NIST SP 800-38D Test Case 13: empty plaintext, verify tag + using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew(); + + int initResult = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC13_Key, Nist_TC13_Nonce); + Assert.Equal(1, initResult); + + byte[] output = new byte[16]; + int finalResult = NativeMethods.EvpFinal(ctx, output, out int outLen); + Assert.Equal(1, finalResult); + Assert.Equal(0, outLen); // No ciphertext for empty plaintext + + byte[] tag = new byte[16]; + int tagResult = NativeMethods.EvpCipherCtxCtrl(ctx, CtrlFlag.GetTag, 16, tag); + Assert.Equal(1, tagResult); + + Assert.Equal(Nist_TC13_Tag, tag); + } + + [Fact] + public void Encrypt_16BytePlaintext_MatchesNistTC14() + { + // NIST SP 800-38D Test Case 14: 16-byte plaintext, verify ciphertext + tag + using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew(); + + int initResult = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC14_Key, Nist_TC14_Nonce); + Assert.Equal(1, initResult); + + byte[] ciphertext = new byte[16]; + int updateResult = NativeMethods.EvpUpdate(ctx, ciphertext, out int ctLen, Nist_TC14_Plaintext, Nist_TC14_Plaintext.Length); + Assert.Equal(1, updateResult); + Assert.Equal(16, ctLen); + + byte[] finalBuffer = new byte[16]; + int finalResult = NativeMethods.EvpFinal(ctx, finalBuffer, out int finalLen); + Assert.Equal(1, finalResult); + Assert.Equal(0, finalLen); + + byte[] tag = new byte[16]; + int tagResult = NativeMethods.EvpCipherCtxCtrl(ctx, CtrlFlag.GetTag, 16, tag); + Assert.Equal(1, tagResult); + + Assert.Equal(Nist_TC14_Ciphertext, ciphertext); + Assert.Equal(Nist_TC14_Tag, tag); + } + + [Fact] + public void RoundTrip_WithAAD_DecryptsSuccessfully() + { + byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray(); + byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64 }; // "Hello World" + byte[] aad = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; + + // Encrypt + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag; + + using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew()) + { + int initResult = NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce); + Assert.Equal(1, initResult); + + // Add AAD (output = null → input is AAD) + int aadResult = NativeMethods.EvpUpdate(encCtx, null, out int aadLen, aad, aad.Length); + Assert.Equal(1, aadResult); + + // Encrypt plaintext + int updateResult = NativeMethods.EvpUpdate(encCtx, ciphertext, out int ctLen, plaintext, plaintext.Length); + Assert.Equal(1, updateResult); + Assert.Equal(plaintext.Length, ctLen); + + byte[] finalBuffer = new byte[16]; + int finalResult = NativeMethods.EvpFinal(encCtx, finalBuffer, out int finalLen); + Assert.Equal(1, finalResult); + + tag = new byte[16]; + int tagResult = NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag); + Assert.Equal(1, tagResult); + } + + // Decrypt + byte[] decrypted = new byte[ciphertext.Length]; + + using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew()) + { + int initResult = NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce); + Assert.Equal(1, initResult); + + // Add AAD + int aadResult = NativeMethods.EvpUpdate(decCtx, null, out int aadLen, aad, aad.Length); + Assert.Equal(1, aadResult); + + // Decrypt ciphertext + int updateResult = NativeMethods.EvpUpdate(decCtx, decrypted, out int ptLen, ciphertext, ciphertext.Length); + Assert.Equal(1, updateResult); + Assert.Equal(ciphertext.Length, ptLen); + + // Set expected tag before finalize + int setTagResult = NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag); + Assert.Equal(1, setTagResult); + + byte[] finalBuffer = new byte[16]; + int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out int finalLen); + Assert.Equal(1, finalResult); // Tag verification succeeded + } + + Assert.Equal(plaintext, decrypted); + } + + [Fact] + public void Decrypt_TamperedTag_FailsAuthentication() + { + byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray(); + byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; + byte[] aad = new byte[] { 0xAA, 0xBB }; + + // Encrypt + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag; + + using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew()) + { + NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce); + NativeMethods.EvpUpdate(encCtx, null, out _, aad, aad.Length); + NativeMethods.EvpUpdate(encCtx, ciphertext, out _, plaintext, plaintext.Length); + byte[] finalBuffer = new byte[16]; + NativeMethods.EvpFinal(encCtx, finalBuffer, out _); + + tag = new byte[16]; + NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag); + } + + // Tamper with tag + tag[0] ^= 0x01; + + // Decrypt with tampered tag + byte[] decrypted = new byte[ciphertext.Length]; + + using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew()) + { + NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce); + NativeMethods.EvpUpdate(decCtx, null, out _, aad, aad.Length); + NativeMethods.EvpUpdate(decCtx, decrypted, out _, ciphertext, ciphertext.Length); + NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag); + + byte[] finalBuffer = new byte[16]; + int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out _); + + // Tag verification must fail + Assert.Equal(0, finalResult); + } + } + + [Fact] + public void Decrypt_ModifiedAAD_FailsAuthentication() + { + byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray(); + byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C }; + byte[] aad = new byte[] { 0xAA, 0xBB, 0xCC }; + + // Encrypt + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag; + + using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew()) + { + NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce); + NativeMethods.EvpUpdate(encCtx, null, out _, aad, aad.Length); + NativeMethods.EvpUpdate(encCtx, ciphertext, out _, plaintext, plaintext.Length); + byte[] finalBuffer = new byte[16]; + NativeMethods.EvpFinal(encCtx, finalBuffer, out _); + + tag = new byte[16]; + NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag); + } + + // Modify AAD + byte[] modifiedAad = (byte[])aad.Clone(); + modifiedAad[0] ^= 0x01; + + // Decrypt with modified AAD + byte[] decrypted = new byte[ciphertext.Length]; + + using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew()) + { + NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce); + NativeMethods.EvpUpdate(decCtx, null, out _, modifiedAad, modifiedAad.Length); + NativeMethods.EvpUpdate(decCtx, decrypted, out _, ciphertext, ciphertext.Length); + NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag); + + byte[] finalBuffer = new byte[16]; + int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out _); + + // AAD verification must fail + Assert.Equal(0, finalResult); + } + } + } +} diff --git a/Yubico.NET.SDK.Performance.sln b/Yubico.NET.SDK.Performance.sln new file mode 100644 index 000000000..f05222e2a --- /dev/null +++ b/Yubico.NET.SDK.Performance.sln @@ -0,0 +1,57 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Yubico.Core", "Yubico.Core", "{35B82F4E-0C73-6F12-221E-2697E560332E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{BDBE9F52-5D81-C56A-52B8-264AD34193D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yubico.Core.Performance", "Yubico.Core\perf\Yubico.Core.Performance.csproj", "{8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yubico.Core", "Yubico.Core\src\Yubico.Core.csproj", "{A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|x64.ActiveCfg = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|x64.Build.0 = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|x86.ActiveCfg = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Debug|x86.Build.0 = Debug|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|Any CPU.Build.0 = Release|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|x64.ActiveCfg = Release|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|x64.Build.0 = Release|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|x86.ActiveCfg = Release|Any CPU + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1}.Release|x86.Build.0 = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|x64.Build.0 = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Debug|x86.Build.0 = Debug|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|Any CPU.Build.0 = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|x64.ActiveCfg = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|x64.Build.0 = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|x86.ActiveCfg = Release|Any CPU + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BDBE9F52-5D81-C56A-52B8-264AD34193D7} = {35B82F4E-0C73-6F12-221E-2697E560332E} + {8E70D7C2-EA69-4ED6-BE78-67573EA1BBB1} = {BDBE9F52-5D81-C56A-52B8-264AD34193D7} + {A4F23AEC-4D6D-45D6-9AE2-324FBCAEE58C} = {BDBE9F52-5D81-C56A-52B8-264AD34193D7} + EndGlobalSection +EndGlobal diff --git a/Yubico.NET.SDK.sln b/Yubico.NET.SDK.sln index 48e23344a..ce34c7a69 100644 --- a/Yubico.NET.SDK.sln +++ b/Yubico.NET.SDK.sln @@ -367,60 +367,176 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apdu", "Apdu", "{1B24E020-1 Yubico.YubiKey\docs\users-manual\application-u2f\apdu\verify-pin.md = Yubico.YubiKey\docs\users-manual\application-u2f\apdu\verify-pin.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "fuzz", "fuzz", "{A78EC702-1330-E7B4-393A-F2146A6F0E95}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yubico.Core.Fuzz", "Yubico.Core\fuzz\Yubico.Core.Fuzz.csproj", "{BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|x64.Build.0 = Debug|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Debug|x86.Build.0 = Debug|Any CPU {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|Any CPU.Build.0 = Release|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|x64.ActiveCfg = Release|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|x64.Build.0 = Release|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|x86.ActiveCfg = Release|Any CPU + {E7157F16-ACD9-427D-A0D3-568B4EF91E7C}.Release|x86.Build.0 = Release|Any CPU {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|x64.Build.0 = Debug|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Debug|x86.Build.0 = Debug|Any CPU {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|Any CPU.ActiveCfg = Release|Any CPU {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|Any CPU.Build.0 = Release|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|x64.ActiveCfg = Release|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|x64.Build.0 = Release|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|x86.ActiveCfg = Release|Any CPU + {C580309E-4712-434A-BAFB-5BF1932860EE}.Release|x86.Build.0 = Release|Any CPU {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|x64.Build.0 = Debug|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Debug|x86.Build.0 = Debug|Any CPU {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|Any CPU.ActiveCfg = Release|Any CPU {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|Any CPU.Build.0 = Release|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|x64.ActiveCfg = Release|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|x64.Build.0 = Release|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|x86.ActiveCfg = Release|Any CPU + {0D349F5B-7C87-4A2D-B9E1-D5805F033FB0}.Release|x86.Build.0 = Release|Any CPU {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|x64.Build.0 = Debug|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Debug|x86.Build.0 = Debug|Any CPU {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|Any CPU.Build.0 = Release|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|x64.ActiveCfg = Release|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|x64.Build.0 = Release|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|x86.ActiveCfg = Release|Any CPU + {4CBA6ABD-C09D-4227-B6B3-58E30C38EACE}.Release|x86.Build.0 = Release|Any CPU {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|x64.ActiveCfg = Debug|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|x64.Build.0 = Debug|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|x86.ActiveCfg = Debug|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Debug|x86.Build.0 = Debug|Any CPU {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|Any CPU.ActiveCfg = Release|Any CPU {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|Any CPU.Build.0 = Release|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|x64.ActiveCfg = Release|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|x64.Build.0 = Release|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|x86.ActiveCfg = Release|Any CPU + {C378DD92-9107-4B7E-9D4B-59271A8ABB42}.Release|x86.Build.0 = Release|Any CPU {A69022BB-4582-4373-AAC7-62712923559B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A69022BB-4582-4373-AAC7-62712923559B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Debug|x64.ActiveCfg = Debug|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Debug|x64.Build.0 = Debug|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Debug|x86.ActiveCfg = Debug|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Debug|x86.Build.0 = Debug|Any CPU {A69022BB-4582-4373-AAC7-62712923559B}.Release|Any CPU.ActiveCfg = Release|Any CPU {A69022BB-4582-4373-AAC7-62712923559B}.Release|Any CPU.Build.0 = Release|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Release|x64.ActiveCfg = Release|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Release|x64.Build.0 = Release|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Release|x86.ActiveCfg = Release|Any CPU + {A69022BB-4582-4373-AAC7-62712923559B}.Release|x86.Build.0 = Release|Any CPU {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|x64.ActiveCfg = Debug|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|x64.Build.0 = Debug|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|x86.ActiveCfg = Debug|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Debug|x86.Build.0 = Debug|Any CPU {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|Any CPU.ActiveCfg = Release|Any CPU {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|Any CPU.Build.0 = Release|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|x64.ActiveCfg = Release|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|x64.Build.0 = Release|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|x86.ActiveCfg = Release|Any CPU + {B6C9FB57-AF94-4170-AE17-020809E5CED2}.Release|x86.Build.0 = Release|Any CPU {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|x64.ActiveCfg = Debug|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|x64.Build.0 = Debug|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|x86.ActiveCfg = Debug|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Debug|x86.Build.0 = Debug|Any CPU {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|Any CPU.ActiveCfg = Release|Any CPU {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|Any CPU.Build.0 = Release|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|x64.ActiveCfg = Release|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|x64.Build.0 = Release|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|x86.ActiveCfg = Release|Any CPU + {3546DD14-6205-447E-82FA-1B43A7AE2483}.Release|x86.Build.0 = Release|Any CPU {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|x64.Build.0 = Debug|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Debug|x86.Build.0 = Debug|Any CPU {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|Any CPU.Build.0 = Release|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|x64.ActiveCfg = Release|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|x64.Build.0 = Release|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|x86.ActiveCfg = Release|Any CPU + {0AC0433E-2477-42AE-A003-CE2F2772487B}.Release|x86.Build.0 = Release|Any CPU {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|x64.Build.0 = Debug|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Debug|x86.Build.0 = Debug|Any CPU {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|Any CPU.Build.0 = Release|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|x64.ActiveCfg = Release|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|x64.Build.0 = Release|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|x86.ActiveCfg = Release|Any CPU + {396C13D6-0DDA-4B95-BFE9-1ECE3557E6B6}.Release|x86.Build.0 = Release|Any CPU {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|x64.ActiveCfg = Debug|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|x64.Build.0 = Debug|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|x86.ActiveCfg = Debug|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Debug|x86.Build.0 = Debug|Any CPU {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|Any CPU.ActiveCfg = Release|Any CPU {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|Any CPU.Build.0 = Release|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|x64.ActiveCfg = Release|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|x64.Build.0 = Release|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|x86.ActiveCfg = Release|Any CPU + {2AB8AD5F-2449-48A1-9659-F438C690BB03}.Release|x86.Build.0 = Release|Any CPU {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|Any CPU.Build.0 = Debug|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|x64.ActiveCfg = Debug|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|x64.Build.0 = Debug|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|x86.ActiveCfg = Debug|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Debug|x86.Build.0 = Debug|Any CPU {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|Any CPU.ActiveCfg = Release|Any CPU {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|Any CPU.Build.0 = Release|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|x64.ActiveCfg = Release|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|x64.Build.0 = Release|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|x86.ActiveCfg = Release|Any CPU + {769A850B-DEA5-44AD-8F9B-30C72601D851}.Release|x86.Build.0 = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|x64.Build.0 = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Debug|x86.Build.0 = Debug|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|Any CPU.Build.0 = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|x64.ActiveCfg = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|x64.Build.0 = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|x86.ActiveCfg = Release|Any CPU + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -465,6 +581,8 @@ Global {769A850B-DEA5-44AD-8F9B-30C72601D851} = {E7F9924F-227A-455F-B7AB-3352BAB6DA46} {1962BE4F-D9C6-4705-A72B-BD6F6700EC78} = {8CE9438B-FEE8-47BF-B2DB-B5BA7A896774} {1B24E020-1C2A-4A11-BC84-944F6D396861} = {2C7EFA92-1B47-4F5B-91B5-C130BDB19D93} + {A78EC702-1330-E7B4-393A-F2146A6F0E95} = {45D2A3BE-5111-4890-8898-2D43DB658A40} + {BCB675AD-B0E5-4B78-846F-DEB8CE0A56CF} = {A78EC702-1330-E7B4-393A-F2146A6F0E95} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DF71DF2-3B6F-4855-8BC4-35B9714F3B0F} diff --git a/Yubico.NativeShims/build-macOS-local.sh b/Yubico.NativeShims/build-macOS-local.sh new file mode 100755 index 000000000..960d003e2 --- /dev/null +++ b/Yubico.NativeShims/build-macOS-local.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Local arm64 macOS NativeShims build for development — bypasses vcpkg. +# Uses brew OpenSSL@3 instead of vcpkg-bundled OpenSSL. +# Replaces the dylib in the consumed NuGet cache so Phase 3+ P/Invoke +# calls resolve the latest exports without waiting for a NuGet release. +# +# Reverts: mv "$CACHE.original" "$CACHE" +# Re-apply: re-run this script. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NS_DIR="$REPO_ROOT/Yubico.NativeShims" + +# Currently consumed NuGet version (kept in sync with Yubico.Core.csproj). +VERSION="$(sed -En 's/.*Yubico\.NativeShims" Version="([^"]+)".*/\1/p' "$REPO_ROOT/Yubico.Core/src/Yubico.Core.csproj" | head -1)" +if [ -z "$VERSION" ]; then + echo "ERROR: could not detect consumed NativeShims version from Yubico.Core.csproj" >&2 + exit 1 +fi +echo "Consumed NativeShims version: $VERSION" + +CACHE="$HOME/.nuget/packages/yubico.nativeshims/$VERSION/runtimes/osx-arm64/native/libYubico.NativeShims.dylib" +if [ ! -f "$CACHE" ]; then + echo "ERROR: NuGet cache dylib not found at $CACHE" >&2 + echo "Run 'dotnet restore' first." >&2 + exit 1 +fi + +# Configure + build +cd "$NS_DIR" +rm -rf build-local-arm64 +cmake -S . -B build-local-arm64 \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@3 \ + -DOPENSSL_USE_STATIC_LIBS=FALSE +cmake --build build-local-arm64 -j + +DYLIB="$NS_DIR/build-local-arm64/libYubico.NativeShims.dylib" + +# Export-table parity check — fail fast if exports.llvm drifted from impl +bash "$NS_DIR/tests/check_exports.sh" "$DYLIB" + +# Backup once, then override +if [ ! -f "$CACHE.original" ]; then + cp "$CACHE" "$CACHE.original" + echo "Backup created: $CACHE.original" +fi +cp "$DYLIB" "$CACHE" +echo "Override applied: $CACHE" + +# Sanity report +echo "--- new dylib ---" +file "$DYLIB" +shasum -a 256 "$DYLIB" diff --git a/Yubico.NativeShims/exports.gnu b/Yubico.NativeShims/exports.gnu index cacb72d45..8712915fc 100644 --- a/Yubico.NativeShims/exports.gnu +++ b/Yubico.NativeShims/exports.gnu @@ -10,6 +10,7 @@ Native_EC_GROUP_new_by_curve_name; Native_EC_POINT_free; Native_EC_POINT_get_affine_coordinates; + Native_EC_POINT_is_on_curve; Native_EC_POINT_mul; Native_EC_POINT_new; Native_EC_POINT_set_affine_coordinates; diff --git a/Yubico.NativeShims/exports.llvm b/Yubico.NativeShims/exports.llvm index 949b70e4f..52139dbf8 100644 --- a/Yubico.NativeShims/exports.llvm +++ b/Yubico.NativeShims/exports.llvm @@ -8,6 +8,7 @@ _Native_EC_GROUP_free _Native_EC_GROUP_new_by_curve_name _Native_EC_POINT_free _Native_EC_POINT_get_affine_coordinates +_Native_EC_POINT_is_on_curve _Native_EC_POINT_mul _Native_EC_POINT_new _Native_EC_POINT_set_affine_coordinates diff --git a/Yubico.NativeShims/exports.msvc b/Yubico.NativeShims/exports.msvc index bd91bae85..382d662b4 100644 --- a/Yubico.NativeShims/exports.msvc +++ b/Yubico.NativeShims/exports.msvc @@ -9,6 +9,7 @@ EXPORTS Native_EC_GROUP_new_by_curve_name Native_EC_POINT_free Native_EC_POINT_get_affine_coordinates + Native_EC_POINT_is_on_curve Native_EC_POINT_mul Native_EC_POINT_new Native_EC_POINT_set_affine_coordinates diff --git a/Yubico.NativeShims/ssl.ecpoint.c b/Yubico.NativeShims/ssl.ecpoint.c index 41249902a..0d337a406 100644 --- a/Yubico.NativeShims/ssl.ecpoint.c +++ b/Yubico.NativeShims/ssl.ecpoint.c @@ -64,3 +64,19 @@ Native_EC_POINT_mul( { return EC_POINT_mul(group, r, n, q, m, ctx); } + +// Validates that an EC_POINT lies on the curve defined by the EC_GROUP. +// Returns 1 if the point is on the curve, 0 if not, -1 on error. +// Required for ARKG-P256 input validation: untrusted public keys (pkBl, pkKem) +// received from authenticator responses MUST be validated before use to prevent +// invalid-curve attacks. +int32_t +NATIVEAPI +Native_EC_POINT_is_on_curve( + const Native_EC_GROUP group, + const Native_EC_POINT point, + Native_BN_CTX ctx + ) +{ + return EC_POINT_is_on_curve(group, point, ctx); +} diff --git a/Yubico.NativeShims/tests/check_exports.ps1 b/Yubico.NativeShims/tests/check_exports.ps1 new file mode 100644 index 000000000..66a414cff --- /dev/null +++ b/Yubico.NativeShims/tests/check_exports.ps1 @@ -0,0 +1,76 @@ +# Validate that a built Yubico.NativeShims.dll exports exactly the canonical +# set of symbols defined in expected_symbols.txt. +# +# Usage: pwsh check_exports.ps1 +# +# Requires: dumpbin.exe on PATH (provided by VC++ Build Tools / vcvars). +# Catches: symbols dropped from exports.msvc, drift between the .def file and +# the actual implementation. Works on cross-compiled binaries (arm64 DLLs +# inspected from x64 host) because dumpbin reads file metadata. +# +# Exits non-zero on any mismatch (missing or extra symbol). + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$LibraryPath +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$expectedFile = Join-Path $scriptDir 'expected_symbols.txt' + +if (-not (Test-Path $LibraryPath)) { + Write-Error "shared library not found: $LibraryPath" + exit 2 +} +if (-not (Test-Path $expectedFile)) { + Write-Error "expected_symbols.txt not found at $expectedFile" + exit 2 +} + +# Load expected symbols (strip comments + blanks) +$expected = Get-Content $expectedFile | + Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne '' } | + ForEach-Object { $_.Trim() } | + Sort-Object -Unique + +# Extract exported names from the DLL via dumpbin /exports. +# Output format includes a header and a "name" column at the end of each +# export line. We grep for lines containing a Native_* token. +$dumpbinOutput = & dumpbin /exports $LibraryPath 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Error "dumpbin failed (exit $LASTEXITCODE). Make sure VC++ Build Tools are on PATH (run vcvars*.bat first)." + exit 2 +} + +$actual = $dumpbinOutput | + Select-String -Pattern '\bNative_\w+' -AllMatches | + ForEach-Object { $_.Matches.Value } | + Sort-Object -Unique + +$missing = $expected | Where-Object { $actual -notcontains $_ } +$extra = $actual | Where-Object { $expected -notcontains $_ } + +Write-Host "Library: $LibraryPath" +Write-Host "Expected: $($expected.Count) symbols" +Write-Host "Actual: $($actual.Count) Native_* symbols" + +$status = 0 +if ($missing) { + Write-Host "" + Write-Host "FAIL: symbols listed in expected_symbols.txt but NOT exported by the binary:" + $missing | ForEach-Object { Write-Host " - $_" } + $status = 1 +} +if ($extra) { + Write-Host "" + Write-Host "FAIL: Native_* symbols exported by the binary but NOT in expected_symbols.txt:" + $extra | ForEach-Object { Write-Host " - $_" } + $status = 1 +} + +if ($status -eq 0) { + Write-Host "PASS: export table matches expected symbol list" +} +exit $status diff --git a/Yubico.NativeShims/tests/check_exports.sh b/Yubico.NativeShims/tests/check_exports.sh new file mode 100755 index 000000000..f3351159b --- /dev/null +++ b/Yubico.NativeShims/tests/check_exports.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Validate that a built Yubico.NativeShims shared library exports exactly the +# canonical set of symbols defined in expected_symbols.txt. +# +# Usage: check_exports.sh +# +# Catches: symbols dropped from exports.gnu / exports.llvm, accidental static +# qualifier on a Native_* function, regressions where the export-file list +# drifts from the actual implementation. Works on cross-compiled binaries +# because nm operates on file metadata, not runtime loading. +# +# Exits non-zero on any mismatch (missing or extra symbol). + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +LIB="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXPECTED_FILE="$SCRIPT_DIR/expected_symbols.txt" + +if [ ! -f "$LIB" ]; then + echo "ERROR: shared library not found: $LIB" >&2 + exit 2 +fi +if [ ! -f "$EXPECTED_FILE" ]; then + echo "ERROR: expected_symbols.txt not found at $EXPECTED_FILE" >&2 + exit 2 +fi + +# Strip comments + blank lines from expected list +EXPECTED=$(grep -v '^[[:space:]]*#' "$EXPECTED_FILE" | grep -v '^[[:space:]]*$' | sort -u) + +# Extract Native_* symbols from the binary. +# macOS: nm -gU lists external defined; symbols carry leading underscore. +# Linux: nm -D --defined-only lists dynamic-section defined symbols. +UNAME="$(uname -s)" +case "$UNAME" in + Darwin) + ACTUAL=$(nm -gU "$LIB" | awk '{print $NF}' | sed 's/^_//' | grep '^Native_' | sort -u) + ;; + Linux) + ACTUAL=$(nm -D --defined-only "$LIB" | awk '{print $NF}' | grep '^Native_' | sort -u) + ;; + *) + echo "ERROR: unsupported host OS '$UNAME' (expected Darwin or Linux)" >&2 + exit 2 + ;; +esac + +MISSING=$(comm -23 <(echo "$EXPECTED") <(echo "$ACTUAL") || true) +EXTRA=$(comm -13 <(echo "$EXPECTED") <(echo "$ACTUAL") || true) + +EXPECTED_COUNT=$(echo "$EXPECTED" | wc -l | tr -d ' ') +ACTUAL_COUNT=$(echo "$ACTUAL" | wc -l | tr -d ' ') + +echo "Library: $LIB" +echo "Expected: $EXPECTED_COUNT symbols" +echo "Actual: $ACTUAL_COUNT Native_* symbols" + +STATUS=0 +if [ -n "$MISSING" ]; then + echo "" + echo "FAIL: symbols listed in expected_symbols.txt but NOT exported by the binary:" + echo "$MISSING" | sed 's/^/ - /' + STATUS=1 +fi +if [ -n "$EXTRA" ]; then + echo "" + echo "FAIL: Native_* symbols exported by the binary but NOT in expected_symbols.txt:" + echo "$EXTRA" | sed 's/^/ - /' + STATUS=1 +fi + +if [ $STATUS -eq 0 ]; then + echo "PASS: export table matches expected symbol list" +fi +exit $STATUS diff --git a/Yubico.NativeShims/tests/expected_symbols.txt b/Yubico.NativeShims/tests/expected_symbols.txt new file mode 100644 index 000000000..1ae1e75dd --- /dev/null +++ b/Yubico.NativeShims/tests/expected_symbols.txt @@ -0,0 +1,58 @@ +# Canonical list of symbols Yubico.NativeShims must export. +# Source of truth — when adding/removing a Native_* function, update this list +# AND the per-platform export files (exports.gnu, exports.llvm, exports.msvc). +# +# Format: one symbol name per line (no underscore prefix). Lines starting with +# '#' and blank lines are ignored. +# +# Consumed by tests/check_exports.sh (POSIX) and tests/check_exports.ps1 +# (Windows) to validate that every built shared library exports exactly this +# set — no missing entries, no extras. + +# --- BIGNUM (ssl.bignum.c) --- +Native_BN_new +Native_BN_bin2bn +Native_BN_bn2bin +Native_BN_bn2binpad +Native_BN_clear_free +Native_BN_num_bytes + +# --- EC group (ssl.ecgroup.c) --- +Native_EC_GROUP_free +Native_EC_GROUP_new_by_curve_name + +# --- EC point (ssl.ecpoint.c) --- +Native_EC_POINT_free +Native_EC_POINT_get_affine_coordinates +Native_EC_POINT_is_on_curve +Native_EC_POINT_mul +Native_EC_POINT_new +Native_EC_POINT_set_affine_coordinates + +# --- AES-256-GCM via EVP (ssl.gcmevp.c) --- +Native_EVP_CIPHER_CTX_new +Native_EVP_CIPHER_CTX_free +Native_EVP_Aes256Gcm_Init +Native_EVP_Update +Native_EVP_Final_ex +Native_EVP_CIPHER_CTX_ctrl + +# --- CMAC via EVP MAC (ssl.cmac.c) --- +Native_CMAC_EVP_MAC_CTX_new +Native_EVP_MAC_CTX_free +Native_CMAC_EVP_MAC_init +Native_CMAC_EVP_MAC_update +Native_CMAC_EVP_MAC_final + +# --- PC/SC smart card (pcsc.c) --- +Native_SCardBeginTransaction +Native_SCardCancel +Native_SCardConnect +Native_SCardDisconnect +Native_SCardEndTransaction +Native_SCardEstablishContext +Native_SCardGetStatusChange +Native_SCardListReaders +Native_SCardReconnect +Native_SCardReleaseContext +Native_SCardTransmit diff --git a/Yubico.YubiKey/src/Yubico.YubiKey.csproj b/Yubico.YubiKey/src/Yubico.YubiKey.csproj index c1a2b0dd7..87997d0b9 100644 --- a/Yubico.YubiKey/src/Yubico.YubiKey.csproj +++ b/Yubico.YubiKey/src/Yubico.YubiKey.csproj @@ -104,14 +104,14 @@ limitations under the License. --> - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all @@ -123,10 +123,10 @@ limitations under the License. --> all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs index 3613d49c1..0503bd328 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs @@ -18,6 +18,7 @@ using System.Globalization; using System.Linq; using System.Security.Cryptography; +using Yubico.Core.Cryptography; using Yubico.YubiKey.Cryptography; using Yubico.YubiKey.Fido2.Cbor; using Yubico.YubiKey.Fido2.Cose; @@ -360,15 +361,21 @@ public class AuthenticatorInfo public IReadOnlyList TransportsForReset { get; } = new List(); /// - /// If present, an encrypted identifier that the platform can use to identify the authenticator across resets. - /// The platform must use the persistent UV auth token as input to decrypt the identifier. - /// If null, the authenticator does not support this feature. + /// + /// If present, an encrypted identifier that the platform can use to identify the authenticator across various sessions, states, and operations. The identifier is only set to a new random value when the YubiKey's FIDO2 application is reset, as is required by the CTAP 2.3 spec (section 6.6). + /// + /// + /// The platform must use the Persistent PinUvAuthToken (PPUAT) as input to decrypt the identifier (see ). /// The encrypted identifier is 32 bytes: the first 16 bytes are the IV, /// and the second 16 bytes are the ciphertext. /// The encryption algorithm is AES-128-CBC. - /// The key is derived from the persistent UV auth token using HKDF-SHA-256 + /// The key is derived from the PPUAT using HKDF-SHA-256 /// with the info string "encIdentifier" and a salt of 32 bytes of 0x00. /// The plaintext is 16 bytes. + /// + /// + /// EncIdentifier is supported in YubiKey firmware version 5.8 and later. If null, the authenticator does not support this feature. + /// /// public ReadOnlyMemory? EncIdentifier { get; } @@ -389,15 +396,21 @@ public class AuthenticatorInfo public IReadOnlyList AttestationFormats { get; } = new List(); /// - /// If present, an encrypted credential store state that the platform can use to detect credential store changes across resets. - /// The platform must use the persistent UV auth token as input to decrypt the state. - /// If null, the authenticator does not support this feature. + /// + /// If present, an encrypted credential store state that the platform can use to detect credential store changes. The credential store state is only set to a new random value after resetting the FIDO2 application, adding or deleting a discoverable credential, and updating a credential's user information, as required by the CTAP 2.3 spec (see section 6.6, section 6.1.2, section 6.8.5, and section 6.8.6) + /// + /// + /// The platform must use the Persistent PinUvAuthToken (PPUAT) as input to decrypt the credential store state (see ). /// The encrypted state is 32 bytes: the first 16 bytes are the IV, /// and the second 16 bytes are the ciphertext. /// The encryption algorithm is AES-128-CBC. - /// The key is derived from the persistent UV auth token using HKDF-SHA-256 + /// The key is derived from the PPUAT using HKDF-SHA-256 /// with the info string "encCredStoreState" and a salt of 32 bytes of 0x00. /// The plaintext is 16 bytes. + /// + /// + /// EncCredStoreState is supported in YubiKey firmware version 5.8 and later. If null, the authenticator does not support this feature. + /// /// public ReadOnlyMemory? EncCredStoreState { get; } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs index 5055fe1a0..a71258ae1 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs @@ -126,7 +126,7 @@ public sealed partial class Fido2Session : ApplicationSession public AuthenticatorInfo AuthenticatorInfo => _authenticatorInfo ??= GetAuthenticatorInfoInternal(); /// - /// Retrieves and decrypts the authenticator's unique 128-bit device identifier. + /// Retrieves and decrypts the authenticator's unique 128-bit device identifier. It will call the KeyCollector to retrieve a persistent PIN/UV Authentication Token (PPUAT), which is required to perform the decryption operation. /// /// /// @@ -135,12 +135,12 @@ public sealed partial class Fido2Session : ApplicationSession /// containing a device identifier that is unique to the authenticator. /// /// - /// A valid and active persistent PIN/UV Authentication Token is automatically obtained if needed. + /// A valid and active PPUAT is automatically obtained. /// The authenticator must support and return the `encIdentifier` in its `getInfo` response (YubiKeys v5.8.0 and later). /// /// - /// The identifier remains constant across PIN changes and resets, allowing platforms to track - /// the same physical authenticator across different sessions and states. + /// The identifier remains constant across PIN changes and other FIDO2 operations, allowing platforms to track + /// the same physical authenticator across different sessions and states. The identifier is only set to a new random value when the YubiKey's FIDO2 application is reset, as is required by the CTAP 2.3 spec (section 6.6). /// /// /// @@ -149,9 +149,9 @@ public sealed partial class Fido2Session : ApplicationSession /// /// /// Returns null if: - /// - The YubiKey firmware does not support this feature (firmware < 5.8.0) - /// - The persistent PIN/UV auth token could not be obtained - /// - The user cancels PIN entry when prompted + ///
- The YubiKey firmware does not support this feature (firmware < 5.8.0). + ///
- The PPUAT could not be obtained. + ///
- The user cancels PIN entry when prompted. ///
/// /// Always check result.HasValue before accessing result.Value. @@ -170,22 +170,21 @@ public ReadOnlyMemory? AuthenticatorIdentifier } /// - /// Retrieves and decrypts the authenticator's credential store state. + /// Retrieves and decrypts the authenticator's credential store state. It will call the KeyCollector to retrieve a persistent PIN/UV Authentication Token (PPUAT), which is required to perform the decryption operation. /// /// /// /// This property leverages the encCredStoreState value obtained from the authenticator's /// authenticatorGetInfo response. The encCredStoreState is an encrypted byte string - /// that platforms can use to detect credential store changes across resets. + /// that platforms can use to detect credential store changes. The credential store state is only set to a new random value after resetting the FIDO2 application, adding or deleting a discoverable credential, and updating a credential's user information, as required by the CTAP 2.3 spec (see section 6.6, section 6.1.2, section 6.8.5, and section 6.8.6) /// /// - /// A valid and active persistent PIN/UV Authentication Token is automatically obtained if needed. + /// A valid and active PPUAT is automatically obtained. /// The authenticator must support and return the `encCredStoreState` in its `getInfo` response (YubiKeys v5.8.0 and later). /// /// /// By comparing the credential store state before and after operations (or across sessions), platforms can detect - /// when credentials have been added, removed, or when the authenticator has been reset. The state changes - /// whenever the credential store is modified. + /// when important authenticator operations have taken place and react accordingly (e.g. remove a deleted credential from a list of credentials displayed in an application window). /// /// /// @@ -194,9 +193,9 @@ public ReadOnlyMemory? AuthenticatorIdentifier /// /// /// Returns null if: - /// - The YubiKey firmware does not support this feature (firmware < 5.8.0) - /// - The persistent PIN/UV auth token could not be obtained - /// - The user cancels PIN entry when prompted + ///
- The YubiKey firmware does not support this feature (firmware < 5.8.0). + ///
- The PPUAT could not be obtained. + ///
- The user cancels PIN entry when prompted. ///
/// /// Always check result.HasValue before accessing result.Value. diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Scp/ScpConnection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Scp/ScpConnection.cs index e51ea8318..bd49fa369 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Scp/ScpConnection.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Scp/ScpConnection.cs @@ -33,6 +33,7 @@ public ScpConnection( : base(smartCardDevice, application, null) { var scpPipeline = CreateScpPipeline(keyParameters); + var withErrorHandling = CreateParentPipeline(scpPipeline, application); // Have the base class use the new error augmented pipeline @@ -61,13 +62,10 @@ private static IApduTransform CreateParentPipeline(IApduTransform pipeline, Yubi private ScpApduTransform CreateScpPipeline(ScpKeyParameters keyParameters) { - // Get the current pipeline - var previousPipeline = GetPipeline(); - - // Wrap the pipeline in ScpApduTransform - var scpApduTransform = new ScpApduTransform(previousPipeline, keyParameters); + // Use GetPipeline() which includes CommandChaining(255). + // Transport-level chaining handles large encrypted APDUs. + var scpApduTransform = new ScpApduTransform(GetPipeline(), keyParameters); - // Return both pipeline return scpApduTransform; } diff --git a/Yubico.YubiKey/tests/integration/Yubico.YubiKey.IntegrationTests.csproj b/Yubico.YubiKey/tests/integration/Yubico.YubiKey.IntegrationTests.csproj index 09767e9e2..1ca7ceb13 100644 --- a/Yubico.YubiKey/tests/integration/Yubico.YubiKey.IntegrationTests.csproj +++ b/Yubico.YubiKey/tests/integration/Yubico.YubiKey.IntegrationTests.csproj @@ -31,19 +31,19 @@ limitations under the License. --> - - + + - + - - + + - + diff --git a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/FidoIntegrationTestBase.cs b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/FidoIntegrationTestBase.cs index 8642a716e..6d6565701 100644 --- a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/FidoIntegrationTestBase.cs +++ b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/FidoIntegrationTestBase.cs @@ -14,6 +14,7 @@ using System; using Xunit; +using Yubico.PlatformInterop; using Yubico.YubiKey.Fido2.Commands; using Yubico.YubiKey.TestUtilities; @@ -62,6 +63,10 @@ public class FidoSessionIntegrationTestBase : IDisposable protected FidoSessionIntegrationTestBase() { + Skip.If( + SdkPlatformInfo.OperatingSystem == SdkPlatform.Windows && !SdkPlatformInfo.IsElevated, + "FIDO2 tests require administrator privileges on Windows. Run the test host (IDE or terminal) as Administrator."); + // Clean up any existing credentials for a fresh start try { diff --git a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs index f29464a21..ad8ece954 100644 --- a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs +++ b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs @@ -697,6 +697,91 @@ private byte[] GetValidPin( return pin; } + [SkippableTheory(typeof(DeviceNotFoundException))] + [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] + public void Scp03_Piv_RSA2048Sign_WithCommandChaining_Succeeds( + StandardTestDevice desiredDeviceType, + Transport transport) + { + // This test validates the fix for YESDK-1260: SCP03 command chaining. + // RSA 2048 signing sends 256 bytes of formatted data which exceeds the + // 239-byte SCP chunk limit, triggering command chaining. + + var testDevice = GetDevice(desiredDeviceType, transport); + Assert.True(testDevice.HasFeature(YubiKeyFeature.Scp03)); + + bool useComplexCreds = testDevice.IsFipsSeries || testDevice.IsPinComplexityEnabled; + var mgmtKey = useComplexCreds + ? (ReadOnlyMemory)PivSessionIntegrationTestBase.ComplexManagementKey + : PivSessionIntegrationTestBase.DefaultManagementKey; + + // Reset PIV and generate key WITHOUT SCP03 (simpler setup) + IPublicKey publicKey; + const byte slotNumber = PivSlot.Retired12; + using (var setupSession = new PivSession(testDevice)) + { + setupSession.ResetApplication(); + + if (useComplexCreds) + { + Assert.True( + setupSession.TryChangePin( + PivSessionIntegrationTestBase.DefaultPin, + PivSessionIntegrationTestBase.ComplexPin, + out _), + "Changing the PIN during test setup should succeed."); + Assert.True( + setupSession.TryChangePuk( + PivSessionIntegrationTestBase.DefaultPuk, + PivSessionIntegrationTestBase.ComplexPuk, + out _), + "Changing the PUK during test setup should succeed."); + Assert.True( + setupSession.TryChangeManagementKey( + PivSessionIntegrationTestBase.DefaultManagementKey, + PivSessionIntegrationTestBase.ComplexManagementKey), + "Changing the management key during test setup should succeed."); + } + + Assert.True(setupSession.TryAuthenticateManagementKey(mgmtKey)); + + publicKey = setupSession.GenerateKeyPair( + slotNumber, KeyType.RSA2048, PivPinPolicy.Never, PivTouchPolicy.Never); + } + + // Now open a new session WITH SCP03 to perform the sign operation + using var pivSession = new PivSession(testDevice, Scp03KeyParameters.DefaultKey); + + // Raw data to sign (arbitrary size — gets hashed to 32 bytes by SHA-256) + var dataToSign = new byte[128]; + Random.Shared.NextBytes(dataToSign); + + using var digester = CryptographyProviders.Sha256Creator(); + _ = digester.TransformFinalBlock(dataToSign, 0, dataToSign.Length); + + // PKCS#1 pads the 32-byte hash to match the RSA key size: 2048 bits = 256 bytes. + // 256 bytes exceeds the SCP03 transport limit (~239 bytes after encryption + // overhead), which forces command chaining — the scenario under test. + var formattedData = RsaFormat.FormatPkcs1Sign( + digester.Hash, + RsaFormat.Sha256, + KeyType.RSA2048.GetKeyDefinition().LengthInBits); + + var signature = pivSession.Sign(slotNumber, formattedData); + + // Verify signature using the generated public key + var rsaPublicKey = Assert.IsType(publicKey); + using var rsa = RSA.Create(); + rsa.ImportParameters(rsaPublicKey.Parameters); + var isVerified = rsa.VerifyData( + dataToSign, + signature, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + Assert.True(isVerified, "RSA 2048 signature over SCP03 should be valid"); + } + #region Helpers private static StaticKeys RandomStaticKeys() => diff --git a/Yubico.YubiKey/tests/integration/appsettings.json b/Yubico.YubiKey/tests/integration/appsettings.json index 2898b8e7c..0507a6110 100644 --- a/Yubico.YubiKey/tests/integration/appsettings.json +++ b/Yubico.YubiKey/tests/integration/appsettings.json @@ -2,7 +2,7 @@ "AppName": "Integration", "Logging": { "LogLevel": { - "Yubico": "Debug" + "Yubico": "Information" }, "Console": { "IncludeScopes": true diff --git a/Yubico.YubiKey/tests/sandbox/Yubico.YubiKey.TestApp.csproj b/Yubico.YubiKey/tests/sandbox/Yubico.YubiKey.TestApp.csproj index 58a603601..65a8cf037 100644 --- a/Yubico.YubiKey/tests/sandbox/Yubico.YubiKey.TestApp.csproj +++ b/Yubico.YubiKey/tests/sandbox/Yubico.YubiKey.TestApp.csproj @@ -32,8 +32,8 @@ limitations under the License. --> - - + + diff --git a/Yubico.YubiKey/tests/sandbox/appsettings.json b/Yubico.YubiKey/tests/sandbox/appsettings.json index 9e6820cf6..7ef5aaf28 100644 --- a/Yubico.YubiKey/tests/sandbox/appsettings.json +++ b/Yubico.YubiKey/tests/sandbox/appsettings.json @@ -2,7 +2,7 @@ "AppName": "Sandbox", "Logging": { "LogLevel": { - "Yubico": "Debug" + "Yubico": "Information" }, "Console": { "IncludeScopes": true diff --git a/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj b/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj index 22eef3e95..a0cbf9e23 100644 --- a/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj +++ b/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj @@ -33,16 +33,16 @@ limitations under the License. --> - - + + - + - + PreserveNewest diff --git a/Yubico.YubiKey/tests/utilities/Yubico.YubiKey.TestUtilities.csproj b/Yubico.YubiKey/tests/utilities/Yubico.YubiKey.TestUtilities.csproj index 10ab64420..c4cc5b1ba 100644 --- a/Yubico.YubiKey/tests/utilities/Yubico.YubiKey.TestUtilities.csproj +++ b/Yubico.YubiKey/tests/utilities/Yubico.YubiKey.TestUtilities.csproj @@ -30,8 +30,8 @@ limitations under the License. --> - - + + diff --git a/docs/users-manual/application-fido2/fido2-authenticator-config.md b/docs/users-manual/application-fido2/fido2-authenticator-config.md index 08e4cd41a..5c5d66822 100644 --- a/docs/users-manual/application-fido2/fido2-authenticator-config.md +++ b/docs/users-manual/application-fido2/fido2-authenticator-config.md @@ -51,7 +51,7 @@ Or you can check the "setMinPINLength" option. ```csharp // Get the "setMinPINLength" option to know if it is possible to set the minimum PIN length. - OptionValue setMinPinLenValue = AuthenticatorInfo.GetOptionValue(AuthenticatorOptions.setMinPINLength); + OptionValue setMinPinLenValue = fido2Session.AuthenticatorInfo.GetOptionValue(AuthenticatorOptions.setMinPINLength); // If the option is True, then it is supported, it is possible to set the min PIN length. if (setMinPinLenValue == OptionValue.True) @@ -116,7 +116,7 @@ its state before toggling. } // If this option is False, then it is supported and the YubiKey is not currently set // to always require UV. If you want it set to be always require UV, then toggle. - if (alwaysIvValue == OptionValue.False) + if (alwaysUvValue == OptionValue.False) { return fido2Session.TryToggleAlwaysUv(); } @@ -171,7 +171,7 @@ KeyCollector. If you don't want to build a authenticatorConfig methods. For example: ```csharp - bool isVerified = fido2Session.TryVerifyPin(PinUvAuthTokenPemissions.AuthenticatorConfiguration); + bool isVerified = fido2Session.TryVerifyPin(PinUvAuthTokenPermissions.AuthenticatorConfiguration); ``` ## Enable enterprise attestation diff --git a/docs/users-manual/application-fido2/fido2-pin.md b/docs/users-manual/application-fido2/fido2-pin.md index a50341529..61bc9218a 100644 --- a/docs/users-manual/application-fido2/fido2-pin.md +++ b/docs/users-manual/application-fido2/fido2-pin.md @@ -204,6 +204,7 @@ this. ```csharp char[] pinChars = CollectPin(); + // Note: string cannot be securely wiped from memory — see tradeoff discussion above. string pinAsString = new string(pinChars); string normalizedPin = pinAsString.Normalize(); byte[] utf8Pin = Encoding.UTF8.GetBytes(normalizedPin); diff --git a/docs/users-manual/application-oath/oath-credentials.md b/docs/users-manual/application-oath/oath-credentials.md index a1a2b4922..d52287d1e 100644 --- a/docs/users-manual/application-oath/oath-credentials.md +++ b/docs/users-manual/application-oath/oath-credentials.md @@ -136,7 +136,7 @@ The URI specification [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). If you are unable to capture the QR code and use a URI string, you can manually create the credential by adding the account information. The Issuer is recommended, but not required. -``` +```csharp // create TOTP credential var credential = new Credential { Issuer = "Yubico", @@ -146,7 +146,7 @@ var credential = new Credential { Digits = 6, Secret = "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", RequireTouch = false -} +}; // create HOTP credential var credential = new Credential { @@ -157,5 +157,5 @@ var credential = new Credential { Counter = 0, Secret = "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", RequireTouch = false -} +}; ``` diff --git a/docs/users-manual/application-oath/oath-session.md b/docs/users-manual/application-oath/oath-session.md index a452ebf8a..0a56cd7fc 100644 --- a/docs/users-manual/application-oath/oath-session.md +++ b/docs/users-manual/application-oath/oath-session.md @@ -277,7 +277,7 @@ oathSession.RenameCredential(credentialTotp, "Test", "example@test.com"); // Or // Pass Issuer, AccountName, Type and Period of the credential you want to rename, as well as the new Issuer and AccountName. -Credential credential = RemoveCredential( +Credential credential = oathSession.RenameCredential( "Yubico", "test@yubico.com", "Test", @@ -286,7 +286,7 @@ Credential credential = RemoveCredential( CredentialPeriod.Period60); // Pass just the current and new Issuer and AccountName if the credential has TOTP type and default period. -Credential credential = RemoveCredential( +Credential credential = oathSession.RenameCredential( "Yubico", "test@yubico.com", "Test", diff --git a/docs/users-manual/application-otp/how-to-program-a-challenge-response-credential.md b/docs/users-manual/application-otp/how-to-program-a-challenge-response-credential.md index d1508e4d5..7011e9e66 100644 --- a/docs/users-manual/application-otp/how-to-program-a-challenge-response-credential.md +++ b/docs/users-manual/application-otp/how-to-program-a-challenge-response-credential.md @@ -107,12 +107,22 @@ the button during a challenge-response operation. ```C# using (OtpSession otp = new OtpSession(yubiKey)) { - // The secret key, hmacKey, was set elsewhere. - otp.ConfigureChallengeResponse(Slot.ShortPress) - .UseHmacSha1() - .UseKey(hmacKey) - .UseButton() - .Execute(); + try + { + // The secret key, hmacKey, was set elsewhere. + otp.ConfigureChallengeResponse(Slot.ShortPress) + .UseHmacSha1() + .UseKey(hmacKey) + .UseButton() + .Execute(); + + // Share the secret key with the validation server (if you haven't already) + // before clearing. + } + finally + { + CryptographicOperations.ZeroMemory(hmacKey.Span); + } } ``` @@ -124,13 +134,21 @@ credential. This configuration uses the Yubico OTP algorithm and a randomly gene ```C# using (OtpSession otp = new OtpSession(yubiKey)) { - //Don't forget to share the secret key with the validation server before clearing it from memory. Memory secretKey = new byte[ConfigureYubicoOtp.KeySize]; - otp.ConfigureChallengeResponse(Slot.LongPress) - .UseYubiOtp() - .GenerateKey(secretKey) - .Execute(); + try + { + otp.ConfigureChallengeResponse(Slot.LongPress) + .UseYubiOtp() + .GenerateKey(secretKey) + .Execute(); + + // Share the secret key with the validation server before clearing. + } + finally + { + CryptographicOperations.ZeroMemory(secretKey.Span); + } } ``` diff --git a/docs/users-manual/application-otp/how-to-program-a-yubico-otp-credential.md b/docs/users-manual/application-otp/how-to-program-a-yubico-otp-credential.md index 3928ff771..f85afb6aa 100644 --- a/docs/users-manual/application-otp/how-to-program-a-yubico-otp-credential.md +++ b/docs/users-manual/application-otp/how-to-program-a-yubico-otp-credential.md @@ -51,12 +51,22 @@ credential as follows: ```C# using (OtpSession otp = new OtpSession(yKey)) { - // privateId and aesKey are Memory references. - otp.ConfigureYubicoOtp(Slot.ShortPress) - .UseSerialNumberAsPublicId() - .UsePrivateId(privateId) - .UseKey(aesKey) - .Execute(); + try + { + // privateId and aesKey are Memory references. + otp.ConfigureYubicoOtp(Slot.ShortPress) + .UseSerialNumberAsPublicId() + .UsePrivateId(privateId) + .UseKey(aesKey) + .Execute(); + + // Do whatever is needed with privateId and aesKey before clearing them from memory. + } + finally + { + CryptographicOperations.ZeroMemory(privateId.Span); + CryptographicOperations.ZeroMemory(aesKey.Span); + } } ``` @@ -71,13 +81,21 @@ using (OtpSession otp = new OtpSession(yKey)) Memory privateId = new byte[ConfigureYubicoOtp.PrivateIdentifierSize]; Memory aesKey = new byte[ConfigureYubicoOtp.KeySize]; - otp.ConfigureYubicoOtp(Slot.ShortPress) - .UseSerialNumberAsPublicId() - .GeneratePrivateId(privateId) - .GenerateKey(aesKey) - .Execute(); - - // Do whatever is needed with privateId and aesKey, and clear them. + try + { + otp.ConfigureYubicoOtp(Slot.ShortPress) + .UseSerialNumberAsPublicId() + .GeneratePrivateId(privateId) + .GenerateKey(aesKey) + .Execute(); + + // Do whatever is needed with privateId and aesKey before clearing them from memory. + } + finally + { + CryptographicOperations.ZeroMemory(privateId.Span); + CryptographicOperations.ZeroMemory(aesKey.Span); + } } ``` diff --git a/docs/users-manual/application-otp/how-to-program-an-hotp-credential.md b/docs/users-manual/application-otp/how-to-program-an-hotp-credential.md index bcdcaf552..a797f1843 100644 --- a/docs/users-manual/application-otp/how-to-program-an-hotp-credential.md +++ b/docs/users-manual/application-otp/how-to-program-an-hotp-credential.md @@ -57,9 +57,18 @@ using (OtpSession otp = new OtpSession(yubiKey)) { ReadOnlyMemory hmacKey = new byte[ConfigureHotp.HmacKeySize] {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; - otp.ConfigureHotp(Slot.LongPress) - .UseKey(hmacKey) - .Execute(); + try + { + otp.ConfigureHotp(Slot.LongPress) + .UseKey(hmacKey) + .Execute(); + + // Share hmacKey with the validation server before clearing. + } + finally + { + CryptographicOperations.ZeroMemory(hmacKey.Span); + } } ``` @@ -70,9 +79,18 @@ using (OtpSession otp = new OtpSession(yubiKey)) { Memory hmacKey = new byte[ConfigureHotp.HmacKeySize]; - otp.ConfigureHotp(Slot.LongPress) - .GenerateKey(hmacKey) - .Execute(); + try + { + otp.ConfigureHotp(Slot.LongPress) + .GenerateKey(hmacKey) + .Execute(); + + // Share hmacKey with the validation server before clearing. + } + finally + { + CryptographicOperations.ZeroMemory(hmacKey.Span); + } } ``` @@ -102,11 +120,20 @@ using (OtpSession otp = new OtpSession(yubiKey)) { Memory hmacKey = new byte[ConfigureHotp.HmacKeySize]; - otp.ConfigureHotp(Slot.LongPress) - .UseInitialMovingFactor(16) - .GenerateKey(hmacKey) - .Use8Digits() - .Execute(); + try + { + otp.ConfigureHotp(Slot.LongPress) + .UseInitialMovingFactor(16) + .GenerateKey(hmacKey) + .Use8Digits() + .Execute(); + + // Share hmacKey with the validation server before clearing. + } + finally + { + CryptographicOperations.ZeroMemory(hmacKey.Span); + } } ``` diff --git a/docs/users-manual/application-otp/how-to-slot-access-codes.md b/docs/users-manual/application-otp/how-to-slot-access-codes.md index 6d06222c7..d0fef15a6 100644 --- a/docs/users-manual/application-otp/how-to-slot-access-codes.md +++ b/docs/users-manual/application-otp/how-to-slot-access-codes.md @@ -99,6 +99,11 @@ using (OtpSession otp = new OtpSession(yubiKey)) } ``` +> [!NOTE] +> In production code, clear sensitive buffers such as access codes and HMAC keys after use with +> `CryptographicOperations.ZeroMemory()`. See [Sensitive Data](../sdk-programming-guide/sensitive-data.md) +> for details. + ### Example: modify a slot access code To modify a slot's access code, you must provide the current access code diff --git a/docs/users-manual/application-piv/access-control.md b/docs/users-manual/application-piv/access-control.md index f01642617..13eab7b46 100644 --- a/docs/users-manual/application-piv/access-control.md +++ b/docs/users-manual/application-piv/access-control.md @@ -38,9 +38,9 @@ For example, suppose you have some code to generate a key pair. using (var pivSession = new PivSession(yubiKeyToUse)) { pivSession.KeyCollector = SomeKeyCollector; - PivPublicKey publicKey = pivSession.GenerateKeyPair( + IPublicKey publicKey = pivSession.GenerateKeyPair( PivSlot.Authentication, - PivAlgorithm.EccP256, + KeyType.ECP256, PivPinPolicy.Once, PivTouchPolicy.Once); } diff --git a/docs/users-manual/application-piv/attestation.md b/docs/users-manual/application-piv/attestation.md index 297c6336f..8fd7ada6d 100644 --- a/docs/users-manual/application-piv/attestation.md +++ b/docs/users-manual/application-piv/attestation.md @@ -280,7 +280,7 @@ before deployment. There is a method in the `PivSession` class to replace the attestation key and cert. ```csharp -public void ReplaceAttestationKeyAndCertificate(PivPrivateKey privateKey, X509Certificate2 certificate) +public void ReplaceAttestationKeyAndCertificate(IPrivateKey privateKey, X509Certificate2 certificate) ``` If you use this method to replace the key and cert, it will check the certificate to make @@ -301,28 +301,31 @@ class is not one you should use with sensitive data, so we present this techniqu using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -private static bool IsMatchingKeyAndCert(PivPrivateKey privateKey, X509Certificate2 certificate) +private static bool IsMatchingKeyAndCert(IPrivateKey privateKey, X509Certificate2 certificate) { - if (privateKey.Algorithm == PivAlgorithm.Rsa2048) + if (privateKey is RSAPrivateKey rsaPrivateKey) { - return IsMatchingKeyAndCertRsa((PivRsaPrivateKey)privateKey, (RSA)certificate.PublicKey.Key); + return IsMatchingKeyAndCertRsa(rsaPrivateKey, (RSA)certificate.PublicKey.Key); } - return IsMatchingKeyAndCertEcc((PivEccPrivateKey)privateKey, (byte[])certificate.PublicKey.EncodedKeyValue); + if (privateKey is ECPrivateKey ecPrivateKey) + { + return IsMatchingKeyAndCertEcc(ecPrivateKey, (byte[])certificate.PublicKey.EncodedKeyValue); + } + + throw new ArgumentException("Unsupported key type"); } -private static bool IsMatchingKeyAndCertRsa(PivRsaPrivateKey privateKey, RSA publicKey) +private static bool IsMatchingKeyAndCertRsa(RSAPrivateKey privateKey, RSA publicKey) { - bool returnValue = isValidCert; - // In order to build a System.Security.Cryptography.RSA object // that contains the private key, we must provide all possible // components: modulus, public exponent, private exponent, CRT // info. // We have everything needed from the publicKey (an RSA object) - // and privateKey (a PivRsaPrivateKey object) except for the + // and privateKey (an RSAPrivateKey object) except for the // private exponent. If you have the CRT info, you don't need the - // private exponent, so the PivRsaPrivateKey class doesn't keep + // private exponent, so the RSAPrivateKey class doesn't keep // it (and the YubiKey itself does not keep it). // But in order to build the RSA private key-containing object we // need to obtain the private exponent. Except we don't really. @@ -333,6 +336,7 @@ private static bool IsMatchingKeyAndCertRsa(PivRsaPrivateKey privateKey, RSA pub // using an arbitrary private exponent. RSAParameters publicParams = publicKey.ExportParameters(false); + RSAParameters keyParams = privateKey.Parameters; byte[] fakeExponent = new byte[publicParams.Modulus.Length]; byte[] modCopy = new byte[publicParams.Modulus.Length]; byte[] expCopy = new byte[publicParams.Exponent.Length]; @@ -358,11 +362,11 @@ private static bool IsMatchingKeyAndCertRsa(PivRsaPrivateKey privateKey, RSA pub try { rsaParams.D = fakeExponent; - rsaParams.DP = privateKey.ExponentP.ToArray(); - rsaParams.DQ = privateKey.ExponentQ.ToArray(); - rsaParams.InverseQ = privateKey.Coefficient.ToArray(); - rsaParams.P = privateKey.PrimeP.ToArray(); - rsaParams.Q = privateKey.PrimeQ.ToArray(); + rsaParams.DP = keyParams.DP; + rsaParams.DQ = keyParams.DQ; + rsaParams.InverseQ = keyParams.InverseQ; + rsaParams.P = keyParams.P; + rsaParams.Q = keyParams.Q; rsaParams.Modulus = modCopy; rsaParams.Exponent = expCopy; @@ -385,11 +389,9 @@ private static bool IsMatchingKeyAndCertRsa(PivRsaPrivateKey privateKey, RSA pub } } -private static bool IsMatchingKeyAndCertEcc(PivEccPrivateKey privateKey, byte[] publicKey) +private static bool IsMatchingKeyAndCertEcc(ECPrivateKey privateKey, byte[] publicKey) { - bool returnValue = false; - - ECCurve eccCurve = privateKey.Algorithm == PivAlgorithm.EccP256 ? + ECCurve eccCurve = privateKey.KeyType == KeyType.ECP256 ? ECCurve.CreateFromValue("1.2.840.10045.3.1.7") : ECCurve.CreateFromValue("1.3.132.0.34"); @@ -407,7 +409,7 @@ private static bool IsMatchingKeyAndCertEcc(PivEccPrivateKey privateKey, byte[] Array.Copy(publicKey, 1 + coordLength, yCoord, 0, coordLength); eccParams.Q.X = xCoord; eccParams.Q.Y = yCoord; - eccParams.D = privateKey.PrivateValue.ToArray(); + eccParams.D = privateKey.Parameters.D; // To determine if the public key in the cert is the partner // to the private key, sign random data using that private diff --git a/docs/users-manual/application-piv/cert-request.md b/docs/users-manual/application-piv/cert-request.md index a9d858e6f..f23783347 100644 --- a/docs/users-manual/application-piv/cert-request.md +++ b/docs/users-manual/application-piv/cert-request.md @@ -89,20 +89,17 @@ see the .NET documentation. ### Public key -When you generate a key pair on the YubiKey, a `PivPublicKey` is returned. The +When you generate a key pair on the YubiKey, an `IPublicKey` is returned. The `CertificateRequest` class needs that public key as an instance of the `RSA` class. -The `PivSampleCode.KeyConverter` class demonstrates how to get an `RSA` object from a -`PivPublicKey`. Your code might look something like this. +The `PivSampleCode.KeyConverter` class demonstrates how to get an `RSA` object from an +`IPublicKey`. Your code might look something like this. ```csharp - PivRsaPublicKey rsaPublic = pivSession.GenerateKeyPair(...); + var rsaPublic = (RSAPublicKey)pivSession.GenerateKeyPair( + PivSlot.Authentication, KeyType.RSA2048); - var rsaParams = new RSAParameters(); - rsaParams.Modulus = rsaPublic.Modulus.ToArray(); - rsaParams.Exponent = rsaPublic.PublicExponent.ToArray(); - - RSA rsaPublicKeyObject = RSA.Create(rsaParams); + RSA rsaPublicKeyObject = RSA.Create(rsaPublic.Parameters); ``` An `RSA` object can contain a public key only or both public and private keys. Later on, diff --git a/docs/users-manual/application-piv/commands.md b/docs/users-manual/application-piv/commands.md index 5be19b787..454c2d2e1 100644 --- a/docs/users-manual/application-piv/commands.md +++ b/docs/users-manual/application-piv/commands.md @@ -75,8 +75,8 @@ To see the serial number as a decimal string, use `ToString()`. For example, ```C# int serialNumber = serialResponse.GetData(); - string decimalSerial = serialNumber.GetString(); - string hexSerial = serialNumber.GetString("X"); + string decimalSerial = serialNumber.ToString(); + string hexSerial = serialNumber.ToString("X"); // Print out the decimalSerial to get something like "11409355" // Print out the hexSerial to get something like "00AE17CB" diff --git a/docs/users-manual/application-piv/migrate-smartcardnet.md b/docs/users-manual/application-piv/migrate-smartcardnet.md index 621a05563..92faa62ad 100644 --- a/docs/users-manual/application-piv/migrate-smartcardnet.md +++ b/docs/users-manual/application-piv/migrate-smartcardnet.md @@ -282,6 +282,8 @@ In the SmartCard.NET API, here is how you load the MSROOTS data onto the YubiKey ```csharp // Note that there is a limit of 3058 bytes for the data. byte[] msRootsData = CollectMsRootsData(); + // Note: The old API uses string for PINs. The SDK uses byte[] and the + // KeyCollector pattern for secure PIN handling. string pin = CollectPin(); var memoryStream = new MemoryStream(msRootsData); diff --git a/docs/users-manual/application-security-domain/security-domain-keys.md b/docs/users-manual/application-security-domain/security-domain-keys.md index 9b8fc9fb6..7dcb7b3ac 100644 --- a/docs/users-manual/application-security-domain/security-domain-keys.md +++ b/docs/users-manual/application-security-domain/security-domain-keys.md @@ -84,11 +84,11 @@ var publicKey = session.GenerateEcKey(keyRef); ```csharp // Import existing private key -var privateKey = new ECPrivateKeyParameters(ecdsa); +var privateKey = ECPrivateKey.CreateFromParameters(ecdsa.ExportParameters(true)); session.PutKey(keyRef, privateKey); // Import public key -var publicKey = new ECPublicKeyParameters(ecdsaPublic); +var publicKey = ECPublicKey.CreateFromParameters(ecdsaPublic.ExportParameters(false)); session.PutKey(keyRef, publicKey); ``` diff --git a/docs/users-manual/application-u2f/fips-mode.md b/docs/users-manual/application-u2f/fips-mode.md index 10a0db939..62551e40d 100644 --- a/docs/users-manual/application-u2f/fips-mode.md +++ b/docs/users-manual/application-u2f/fips-mode.md @@ -42,15 +42,15 @@ version 5 FIPS series YubiKeys. Even though it is a FIPS-certified device, its F application is not FIPS-compliant. Note that a version 5 FIPS series YubiKey supports FIDO2 and that can be FIPS-compliant. -You can determine programmatically whether a given YubiKey is a 4 FIPS Series key with the -[GetDeviceInfoCommand](u2f-commands.md#get-device-info). +You can determine programmatically whether a given YubiKey is a 4 FIPS Series key using +the device info available through the `IYubiKeyDevice` interface (see +[Get device info](u2f-commands.md#get-device-info) for protocol details). ```c# - var getDeviceInfoCmd = new GetDeviceInfoCommand(); - GetDeviceInfoResponse getDeviceInfoRsp = connection.SendCommand(getDeviceInfoCmd); - YubiKeyDeviceInfo deviceInfo = getDeviceInfoRsp.GetData(); + IYubiKeyDevice yubiKeyDevice = YubiKeyDevice.FindAll().FirstOrDefault() + ?? throw new InvalidOperationException("No YubiKey device found."); - if (deviceInfo.IsFipsSeries && (deviceInfo.FirmwareVersion.Major == 4)) + if (yubiKeyDevice.IsFipsSeries && (yubiKeyDevice.FirmwareVersion.Major == 4)) { // This is a version 4 FIPS YubiKey. } @@ -69,17 +69,17 @@ programmatically determine if a YubiKey is in FIPS mode or not with [VerifyFipsModeCommand](u2f-commands.md#verify-fips-mode). ```c# - var getDeviceInfoCmd = new GetDeviceInfoCommand(); - GetDeviceInfoResponse getDeviceInfoRsp = connection.SendCommand(getDeviceInfoCmd); - YubiKeyDeviceInfo deviceInfo = getDeviceInfoRsp.GetData(); + IYubiKeyDevice yubiKeyDevice = YubiKeyDevice.FindAll().FirstOrDefault() + ?? throw new InvalidOperationException("No YubiKey device found."); - // Is this YubiKey 4 FIPS series? - if (deviceInfo.IsFipsSeries && (deviceInfo.FirmwareVersion.Major == 4)) + // Is this YubiKey 4 FIPS series? + if (yubiKeyDevice.IsFipsSeries && (yubiKeyDevice.FirmwareVersion.Major == 4)) { // If it is YubiKey 4 FIPS series, we can get the FIPS mode. + using IYubiKeyConnection connection = yubiKeyDevice.Connect(YubiKeyApplication.FidoU2f); var vfyFipsModeCmd = new VerifyFipsModeCommand(); VerifyFipsModeResponse vfyFipsModeRsp = connection.SendCommand(vfyFipsModeCmd); - if (vfyFipsMode.GetData()) + if (vfyFipsModeRsp.GetData()) { // If the return from GetData is true, then this is // YubiKey 4 FIPS series in FIPS mode. @@ -88,7 +88,7 @@ programmatically determine if a YubiKey is in FIPS mode or not with } // Note that if the YubiKey is not version 4 FIPS series, the // VerifyFipsModeCommand is undefined. A call to VerifyFipsModeResponse.GetData - // will result in an exception. + // will result in an exception. } ``` diff --git a/docs/users-manual/getting-started/overview-of-sdk.md b/docs/users-manual/getting-started/overview-of-sdk.md index 888f040c0..58a8c6a99 100644 --- a/docs/users-manual/getting-started/overview-of-sdk.md +++ b/docs/users-manual/getting-started/overview-of-sdk.md @@ -209,7 +209,7 @@ public static class Program // Generate a public-private keypair var publicKey = piv.GenerateKeyPair( PivSlot.CardAuthentication, - PivAlgorithm.Rsa2048); + KeyType.RSA2048); } } } diff --git a/docs/users-manual/getting-started/whats-new.md b/docs/users-manual/getting-started/whats-new.md index b0f8297ce..14de5b113 100644 --- a/docs/users-manual/getting-started/whats-new.md +++ b/docs/users-manual/getting-started/whats-new.md @@ -18,6 +18,40 @@ Here you can find all of the updates and release notes for published versions of ## 1.16.x Releases +### 1.16.1 + +Release date: April 29th, 2026 + +Bug Fixes: + +- Fixed an issue where HID and SmartCard transport events were logged at Info level, causing excessive noise in application logs. These events have been downgraded to Debug level. ([#473](https://github.com/Yubico/Yubico.NET.SDK/pull/473)) + +- Fixed an issue where high idle CPU usage occurred in RDS/Terminal Server environments due to `SCARD_E_INVALID_HANDLE` errors in the smart card listener loop. ([#445](https://github.com/Yubico/Yubico.NET.SDK/pull/445)) + +- Several bugs discovered through fuzz testing have been fixed, including issues in TLV parsing and CBOR decoding. A SharpFuzz fuzzing harness and CodeQL scheduled analysis have also been added. ([#458](https://github.com/Yubico/Yubico.NET.SDK/pull/458)) + +Documentation: + +- The documentation on `EncIdentifier` has been corrected with updated details and relevant links. ([#456](https://github.com/Yubico/Yubico.NET.SDK/pull/456)) + +- Important FIDO2 SCP support information has been added to the user's manual. ([#442](https://github.com/Yubico/Yubico.NET.SDK/pull/442)) + +- Inconsistencies and obsolete class references in the documentation have been addressed. ([#441](https://github.com/Yubico/Yubico.NET.SDK/pull/441)) + +Miscellaneous: + +- The Yubico.NativeShims dependency has been switched to a floating version with `packages.lock.json` for safer dependency management. ([#474](https://github.com/Yubico/Yubico.NET.SDK/pull/474)) + +- NativeShims export sanity checks and interop known-answer tests have been added, and Hkdf tests have been relocated. ([#472](https://github.com/Yubico/Yubico.NET.SDK/pull/472)) + +- An SCP03 command chaining regression test has been added. ([#452](https://github.com/Yubico/Yubico.NET.SDK/pull/452)) + +Dependencies: + +- Several dependencies across the Yubico.Core, Yubico.YubiKey, and GitHub Actions workflows have been updated to newer versions. ([#449](https://github.com/Yubico/Yubico.NET.SDK/pull/449), [#453](https://github.com/Yubico/Yubico.NET.SDK/pull/453), [#454](https://github.com/Yubico/Yubico.NET.SDK/pull/454), [#461](https://github.com/Yubico/Yubico.NET.SDK/pull/461), [#462](https://github.com/Yubico/Yubico.NET.SDK/pull/462), [#463](https://github.com/Yubico/Yubico.NET.SDK/pull/463), [#464](https://github.com/Yubico/Yubico.NET.SDK/pull/464), [#470](https://github.com/Yubico/Yubico.NET.SDK/pull/470), [#471](https://github.com/Yubico/Yubico.NET.SDK/pull/471)) + +_________ + ### 1.16.0 Release date: March 31st, 2026 diff --git a/docs/users-manual/sdk-programming-guide/key-collector.md b/docs/users-manual/sdk-programming-guide/key-collector.md index 67aac5cdd..105319213 100644 --- a/docs/users-manual/sdk-programming-guide/key-collector.md +++ b/docs/users-manual/sdk-programming-guide/key-collector.md @@ -246,12 +246,12 @@ For example, here is a possibility. try { int pinLength = CollectPin(pinData); - while (!pivSession.TryVerifyPin(pinData.Slice(0, pinLength, out int? retriesRemaining)) + while (!pivSession.TryVerifyPin(pinData.Slice(0, pinLength), out int? retriesRemaining)) { - pinLength = CollectPin(someMessage, retriesRemaining, pinData)) + pinLength = CollectPin(someMessage, retriesRemaining, pinData); if (pinLength == 0) { - throw OperationCanceledException(message); + throw new OperationCanceledException(message); } } } @@ -465,9 +465,14 @@ using Yubico.YubiKey; public class MyKeyCollector { - private byte[] _currentValue = new byte[MaxValueLength] + private byte[] _currentValue = new byte[MaxValueLength]; private int _currentLength; - public Memory CurrentValue = new Memory(_currentValue); + public Memory CurrentValue; + + public MyKeyCollector() + { + CurrentValue = new Memory(_currentValue); + } public bool SampleKeyCollectorDelegate(KeyEntryData keyEntryData) { @@ -479,7 +484,7 @@ using Yubico.YubiKey; switch (keyEntryData.Request) { case KeyEntryRequest.Release: - CryptographicOperations.ZeroMemory(CurrentValue.Span) + CryptographicOperations.ZeroMemory(CurrentValue.Span); break; case KeyEntryRequest.VerifyPivPin: diff --git a/docs/users-manual/sdk-programming-guide/secure-channel-protocol.md b/docs/users-manual/sdk-programming-guide/secure-channel-protocol.md index 936e49ea2..2d4540a4e 100644 --- a/docs/users-manual/sdk-programming-guide/secure-channel-protocol.md +++ b/docs/users-manual/sdk-programming-guide/secure-channel-protocol.md @@ -78,7 +78,7 @@ The SDK provides a consistent way to use secure channels across different YubiKe ### Common pattern -Each application session (PIV, OATH, OTP, YubiHSM Auth) accepts an optional `ScpKeyParameters` parameter. This can be either `Scp03KeyParameters` or `Scp11KeyParameters` depending on which protocol you want to use. +Each application session (PIV, OATH, OTP, YubiHSM Auth, FIDO2) accepts an optional `ScpKeyParameters` parameter. This can be either `Scp03KeyParameters` or `Scp11KeyParameters` depending on which protocol you want to use. ```csharp // Using SCP03 @@ -97,17 +97,17 @@ var keyReference = KeyReference.Create(keyId, keyVersionNumber); var certificates = sdSession.GetCertificates(keyReference); // Verify the Yubikey's certificate chain against a trusted root using your implementation -CertificateChainVerifier.Verify(certificateList) +CertificateChainVerifier.Verify(certificates); -// Use the verified leaf certificate to construct ECPublicKeyParameters -var publicKey = certificates.Last().GetECDsaPublicKey(); -var scp11Params = new Scp11KeyParameters(keyReference, new ECPublicKeyParameters(publicKey)); +// Use the verified leaf certificate to construct an ECPublicKey +var ecDsa = certificates.Last().GetECDsaPublicKey()!; +var scp11Params = new Scp11KeyParameters(keyReference, ECPublicKey.CreateFromParameters(ecDsa.ExportParameters(false))); // Use SCP11b parameters to open connection using (var pivSession = new PivSession(yubiKeyDevice, scp11Params)) { // All PivSession-commands are now automatically protected by SCP11 - session.GenerateKeyPair(PivSlot.Retired12, PivAlgorithm.EccP256, PivPinPolicy.Always); // Protected by SCP11 + pivSession.GenerateKeyPair(PivSlot.Retired12, KeyType.ECP256, PivPinPolicy.Always); // Protected by SCP11 } ``` @@ -119,7 +119,7 @@ using (var pivSession = new PivSession(yubiKeyDevice, scp11Params)) // Using SCP03 StaticKeys scp03Keys = RetrieveScp03KeySet(); // Your static keys using Scp03KeyParameters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); -using (var pivSession = new PivSession(yubiKeyDevice, scp03params)) +using (var pivSession = new PivSession(yubiKeyDevice, scp03Params)) { // All PivSession-commands are now automatically protected by SCP03 } @@ -137,8 +137,8 @@ using (var pivSession = new PivSession(yubiKeyDevice, scp11Params)) // Using SCP03 StaticKeys scp03Keys = RetrieveScp03KeySet(); // Your static keys -using Scp03KeyParamaters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); -using (var oathSession = new OathSession(yubiKeyDevice, scp03params)) +using Scp03KeyParameters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); +using (var oathSession = new OathSession(yubiKeyDevice, scp03Params)) { // All oathSession-commands are now automatically protected by SCP03 } @@ -156,8 +156,8 @@ using (var oathSession = new OathSession(yubiKeyDevice, scp11Params)) // Using SCP03 StaticKeys scp03Keys = RetrieveScp03KeySet(); // Your static keys -using Scp03KeyParamaters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); -using (var otpSession = new OtpSession(yubiKeyDevice, scp03params)) +using Scp03KeyParameters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); +using (var otpSession = new OtpSession(yubiKeyDevice, scp03Params)) { // All otpSession-commands are now automatically protected by SCP03 } @@ -174,21 +174,43 @@ using (var otpSession = new OtpSession(yubiKeyDevice, scp11Params)) ```csharp // Using SCP03 StaticKeys scp03Keys = RetrieveScp03KeySet(); // Your static keys -using Scp03KeyParamaters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); -using (var yubiHsmSession = new YubiHsmAuthSession(yubiKeyDevice, scp03params)) +using Scp03KeyParameters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); +using (var yubiHsmSession = new YubiHsmAuthSession(yubiKeyDevice, scp03Params)) { // All YubiHsmSession-commands are now automatically protected by SCP03 } // Using SCP11b var keyReference = KeyReference.Create(ScpKeyIds.Scp11B, kvn); -using (var yubiHsmSession = new YubiHsmSession(yubiKeyDevice, scp11Params)) +using (var yubiHsmSession = new YubiHsmAuthSession(yubiKeyDevice, scp11Params)) { // All yubiHsmSession-commands are now automatically protected by SCP11 } ``` +#### FIDO2 with secure channel + +SCP is supported for FIDO2 over both NFC and USB connections for YubiKeys with firmware 5.8 and later. For earlier firmware versions, SCP is supported for FIDO2 over NFC connections only. + +```csharp +// Using SCP03 +StaticKeys scp03Keys = RetrieveScp03KeySet(); // Your static keys +using Scp03KeyParamaters scp03Params = Scp03KeyParameters.FromStaticKeys(scp03Keys); +using (var fido2Session = new Fido2Session(yubiKeyDevice, scp03params)) +{ + // All Fido2Session commands are now automatically protected by SCP03 +} + +// Using SCP11b +var keyReference = KeyReference.Create(ScpKeyIds.Scp11B, kvn); +using (var fido2Session = new Fido2Session(yubiKeyDevice, scp11Params)) +{ + // All Fido2Session commands are now automatically protected by SCP11 +} + +``` + ### Direct connection If you need lower-level control, you can establish secure connections directly using [`Connect`](xref:Yubico.YubiKey.IYubiKeyDevice.Connect*): @@ -420,9 +442,10 @@ Unlike SCP03's static keys, SCP11 uses `Scp11KeyParameters` which can contain: ```csharp // SCP11b basic parameters var keyReference = KeyReference.Create(ScpKeyIds.Scp11B, 0x1); +ECParameters ecParams = ecdsa.ExportParameters(includePrivateParameters: false); var scp11Params = new Scp11KeyParameters( keyReference, - new ECPublicKeyParameters(publicKey)); + ECPublicKey.CreateFromParameters(ecParams)); // SCP11a/c with full certificate chain var scp11Params = new Scp11KeyParameters( @@ -442,10 +465,10 @@ using var session = new SecurityDomainSession(yubiKeyDevice, Scp03KeyParameters. // Generate new EC key pair var keyReference = KeyReference.Create(ScpKeyIds.Scp11B, 0x3); -var publicKey = session.GenerateEcKey(keyReference); +var publicKey = session.GenerateEcKey(keyReference, 0); // Import existing key pair -var privateKey = new ECPrivateKeyParameters(ecdsa); +var privateKey = ECPrivateKey.CreateFromParameters(ecdsa.ExportParameters(true)); session.PutKey(keyReference, privateKey); // Store certificates @@ -480,7 +503,7 @@ var leaf = certificateList.Last(); var ecDsaPublicKey = leaf.PublicKey.GetECDsaPublicKey()!.ExportParameters(false); var keyParams = new Scp11KeyParameters( keyReference, - new ECPublicKeyParameters(ecDsaPublicKey)); + ECPublicKey.CreateFromParameters(ecDsaPublicKey)); // Use with any application using var pivSession = new PivSession(yubiKeyDevice, keyParams); @@ -502,7 +525,8 @@ var newPublicKey = session.GenerateEcKey(keyRef); // Setup off-card entity (OCE) var oceRef = KeyReference.Create(OceKid, kvn); -var ocePublicKey = new ECPublicKeyParameters(oceCerts.Ca.PublicKey.GetECDsaPublicKey()); +var oceEcDsa = oceCerts.Ca.PublicKey.GetECDsaPublicKey()!; +var ocePublicKey = ECPublicKey.CreateFromParameters(oceEcDsa.ExportParameters(false)); session.PutKey(oceRef, ocePublicKey); // Store CA identifier @@ -510,11 +534,12 @@ var ski = GetSubjectKeyIdentifier(oceCerts.Ca); session.StoreCaIssuer(oceRef, ski); // Create SCP11a parameters +// privateKey is the OCE private key as ECDsa var scp11Params = new Scp11KeyParameters( keyRef, - new ECPublicKeyParameters(newPublicKey.Parameters), + newPublicKey, oceRef, - new ECPrivateKeyParameters(privateKey), + ECPrivateKey.CreateFromParameters(privateKey.ExportParameters(true)), certChain); // Use the secure connection