diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a427de6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,39 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "SimpleOTP", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5001], + // "portsAttributes": { + // "5001": { + // "protocol": "https" + // } + // } + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "dotnet restore", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "bierner.github-markdown-preview", + "github.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "Gruntfuggly.todo-tree", + "ms-dotnettools.csdevkit", + "patcx.vscode-nuget-gallery", + "saeris.markdown-github-alerts" + ] + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index ba60355..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -### Description -A clear and concise description of what the bug is. - -### Reproduction steps -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -### Expected behavior -A clear and concise description of what you expected to happen. - -### Screenshots -If applicable, add screenshots to help explain your problem. - -### Environment -Please complete the following information: - - Platform: [e.g. Xamarin.Forms 5] - - OS: [e.g. Windows 10 1909 (10.0.18363)] - - Package version: [e.g. 1.1] - -### Additional context -Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..923928d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,107 @@ +name: "🐞 Bug Report" +description: Create a report to help us improve the library +title: "[Bug]: " +labels: ["bug", "needs-triage"] +assignees: + - xfox111 +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: textarea + id: desc + attributes: + label: Description + description: A clear and concise description of what the bug is. + placeholder: e.g. OtpSecret.CreateNew() creates an empty secret + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + placeholder: e.g. OtpSecret.CreateNew() should create non-empty secret with length of 32 characters when stringified, and a byte array of length 20 with non-zero values + validations: + required: true + + - type: input + attributes: + label: Minimal repoduction environment + description: Provide a link to source code or online editor with minimal reproduction enviornment that showcases the issue + validations: + required: true + + - type: dropdown + id: dotnet-version + attributes: + label: .NET version of your project + options: + - ".NET 8" + - ".NET Preview" + - "Other" + validations: + required: true + + - type: dropdown + id: type + attributes: + label: Project type + options: + - "Console" + - "Class library" + - "Web API (ASP.NET)" + - ".NET MAUI" + - "Other" + validations: + required: true + + - type: dropdown + id: package + attributes: + label: Affected packages + options: + - "SimpleOTP" + - "SimpleOTP.DependencyInjection" + - "Both" + validations: + required: true + + - type: input + id: version + attributes: + label: Package version + placeholder: e.g. 8.0.0 + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false + + - type: dropdown + id: requested-help + attributes: + label: Are you willing to submit a PR for this issue? + options: + - "yes" + - "no" + validations: + required: true + + - type: checkboxes + id: checkboxes + attributes: + label: Validations + description: Before submitting the issue, please make sure you do the following + options: + - label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate. + required: true + - label: The provided reproduction is a minimal reproducible example of the bug. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2ec8479 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json + +blank_issues_enabled: true +contact_links: + - name: Questions & Discussions + url: https://github.com/XFox111/SimpleOTP/discussions + about: Use GitHub discussions for message-board style questions and discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 6532412..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6318208 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,62 @@ +name: "🚀 New feature proposal" +description: Suggest a feature idea for this project +title: "[Feature]: " +labels: ["feature", "needs-triage"] +assignees: + - xfox111 +body: + - type: markdown + attributes: + value: | + Thanks for your interest in the project and taking the time to fill out this feature report! + + - type: textarea + id: proposition + attributes: + label: Proposed solution + description: Describe the solution you'd like + validations: + required: true + + - type: textarea + id: justification + attributes: + label: Justification + description: Is your feature request related to a problem? Please describe. + validations: + required: true + + - type: textarea + id: alts + attributes: + label: Alternatives + description: Describe alternatives you've considered. + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false + + - type: dropdown + id: requested-help + attributes: + label: Are you willing to submit a PR for this issue? + options: + - "yes" + - "no" + validations: + required: true + + - type: checkboxes + id: checkboxes + attributes: + label: Validations + description: Before submitting the issue, please make sure you do the following + options: + - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..51b7c31 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json + +version: 2 +updates: + + - package-ecosystem: "nuget" # See documentation for possible values + directory: "/" # Location of package manifests + assignees: + - "xfox111" + reviewers: + - "xfox111" + schedule: + interval: monthly + rebase-strategy: disabled + open-pull-requests-limit: 20 + + - package-ecosystem: "github-actions" + directory: "/" + assignees: + - "xfox111" + reviewers: + - "xfox111" + schedule: + interval: monthly + rebase-strategy: disabled + open-pull-requests-limit: 20 + + - package-ecosystem: "devcontainers" + directory: "/" + assignees: + - "xfox111" + reviewers: + - "xfox111" + schedule: + interval: monthly + rebase-strategy: disabled + open-pull-requests-limit: 20 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 03bf0bc..3367cb3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,11 @@ -Fixes: +## Description + -## Changelog -- Item 1 -- Item 2 -- Item 3 +### Changelog + -## PR Checklist -- [ ] Update package version and release notes -- [ ] Tests and documentation \ No newline at end of file +Resolves: #issue_number + + + + diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e70b481..a709838 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,10 +1,35 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# name: "CodeQL" on: push: - branches: [ master ] + branches: [ "main" ] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.github/*' + - '!.github/workflows/codeql-analysis.yml' + - '.vscode/*' + - '.devcontainer/*' + - '.assets/*' pull_request: - branches: [ master ] + # The branches below must be a subset of the branches above + branches: [ "main" ] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.github/*' + - '!.github/workflows/codeql-analysis.yml' + - '.vscode/*' + - '.devcontainer/*' + - '.assets/*' + schedule: + - cron: '24 7 * * 3' jobs: analyze: @@ -19,22 +44,40 @@ jobs: fail-fast: false matrix: language: [ 'csharp' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/pr-workflow.yml b/.github/workflows/pr-workflow.yml new file mode 100644 index 0000000..ea56659 --- /dev/null +++ b/.github/workflows/pr-workflow.yml @@ -0,0 +1,40 @@ +name: "Build workflow" + +on: + push: + branches: [ "main" ] + paths-ignore: + - "**.md" + - "LICENSE" + - "PRIVACY" + - ".github/*" + - ".vscode/*" + - ".devcontainer/*" + - "!.github/workflows/pr-workflow.yml" + pull_request: + branches: [ "main" ] + paths-ignore: + - "**.md" + - "LICENSE" + - "PRIVACY" + - ".github/*" + - ".vscode/*" + - ".devcontainer/*" + - "!.github/workflows/pr-workflow.yml" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - run: dotnet restore + - run: dotnet build --no-restore + - run: dotnet test --no-restore --verbosity normal diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml new file mode 100644 index 0000000..08a991f --- /dev/null +++ b/.github/workflows/release-workflow.yml @@ -0,0 +1,52 @@ +name: "Release workflow" + +on: + release: + types: [ published ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - run: dotnet restore + - run: dotnet pack + + - name: Drop artifacts + uses: actions/upload-artifact@main + with: + name: packages + path: libraries/**/Release/EugeneFox.SimpleOTP*.?nupkg + + publish-nuget: + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@main + with: + name: packages + + - name: Publish NuGet and symbols + uses: edumserrano/nuget-push@v1.2.2 + with: + api-key: ${{ secrets.NUGET_API_KEY }} + working-directory: . + + publish-github: + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@main + with: + name: packages + + - name: dotnet nuget push + run: dotnet nuget push *.nupkg -k ${{ secrets.GITHUB_TOKEN }} -s https://nuget.pkg.github.com/xfox111/index.json --skip-duplicate --no-symbols diff --git a/.gitignore b/.gitignore index dfcfd56..5e57f18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,350 +1,484 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d270750 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "bierner.github-markdown-preview", + "github.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "Gruntfuggly.todo-tree", + "ms-dotnettools.csdevkit", + "patcx.vscode-nuget-gallery", + "saeris.markdown-github-alerts" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fcd195d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.insertSpaces": false, + "editor.rulers": [ + { + "column": 120 + } + ], + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "omnisharp.organizeImportsOnFormat": true, +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..e92f322 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,80 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "group": { + "kind": "build", + "isDefault": true + }, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/SimpleOTP.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "restore", + "group": "build", + "command": "dotnet", + "type": "process", + "args": [ + "restore", + "${workspaceFolder}/SimpleOTP.sln" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish: SimpleOTP", + "group": "build", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/libraries/SimpleOTP/SimpleOTP.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish: SimpleOTP.DependencyInjection", + "group": "build", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/libraries/SimpleOTP.DependencyInjection/SimpleOTP.DependencyInjection.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "pack: SimpleOTP", + "group": "build", + "command": "dotnet", + "type": "process", + "args": [ + "pack", + "${workspaceFolder}/libraries/SimpleOTP/SimpleOTP.csproj" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "pack: SimpleOTP.DependencyInjection", + "group": "build", + "command": "dotnet", + "type": "process", + "args": [ + "pack", + "${workspaceFolder}/libraries/SimpleOTP.DependencyInjection/SimpleOTP.DependencyInjection.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/.whitesource b/.whitesource deleted file mode 100644 index 55b922e..0000000 --- a/.whitesource +++ /dev/null @@ -1,12 +0,0 @@ -{ - "scanSettings": { - "baseBranches": [] - }, - "checkRunSettings": { - "vulnerableCheckRunConclusionLevel": "failure", - "displayMode": "diff" - }, - "issueSettings": { - "minSeverityLevel": "LOW" - } -} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9bc1ee2..ad52cd6 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,75 +2,133 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission +* Publishing others' private information, such as a physical or email address, + without their explicit permission * Other conduct which could reasonably be considered inappropriate in a - professional setting + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at opensource@xfox111.net. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +reported to the community leaders responsible for enforcement at +[opensource@xfox111.net](mailto:opensource@xfox111.net). +All complaints will be reviewed and investigated promptly and fairly. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +> Contributor Covenant is released under the [Creative Commons Attribution 4.0 International Public License](https://github.com/EthicalSource/contributor_covenant/blob/release/LICENSE.md). [homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 563dcdb..750858f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,243 +1,2 @@ # Contribution Guidelines -Welcome, and thank you for your interest in contributing to my project! - -There are many ways in which you can contribute, beyond writing code. The goal of this document is to provide a high-level overview of how you can get involved. - -## Table of Contents -- [Contribution Guidelines](#contribution-guidelines) - - [Table of Contents](#table-of-contents) - - [Asking Questions](#asking-questions) - - [Providing Feedback](#providing-feedback) - - [Reporting Issues](#reporting-issues) - - [Look For an Existing Issue](#look-for-an-existing-issue) - - [Writing Good Bug Reports and Feature Requests](#writing-good-bug-reports-and-feature-requests) - - [Final Checklist](#final-checklist) - - [Follow Your Issue](#follow-your-issue) - - [Contributing to codebase](#contributing-to-codebase) - - [Build and run project](#build-and-run-project) - - [Development workflow](#development-workflow) - - [Release](#release) - - [Coding guidelines](#coding-guidelines) - - [Indentation](#indentation) - - [Names](#names) - - [Comments](#comments) - - [Strings](#strings) - - [Style](#style) - - [Finding an issue to work on](#finding-an-issue-to-work-on) - - [Contributing to translations](#contributing-to-translations) - - [Submitting pull requests](#submitting-pull-requests) - - [Spell check errors](#spell-check-errors) - - [Thank You!](#thank-you) - - [Attribution](#attribution) - -## Asking Questions -Have a question? Rather than opening an issue, please ask me directly on opensource@xfox111.net. - -## Providing Feedback -Your comments and feedback are welcome. -You can leave your feedbak on feedback@xfox111.net or on [Feedbacks and reviews](https://github.com/XFox111/SimpleOTP/discussions/3) thread on [GitHub Discussions](https://github.com/XFox111/SimpleOTP/discussions/) - -## Reporting Issues -Have you identified a reproducible problem in the application? Have a feature request? I'd like to hear it! Here's how you can make reporting your issue as effective as possible. - -### Look For an Existing Issue -Before you create a new issue, please do a search in [open issues](https://github.com/xfox111/gutschedule/issues) to see if the issue or feature request has already been filed. - -Be sure to scan through the [feature requests](https://github.com/XFox111/GUTSchedule/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement). - -If you find your issue already exists, make relevant comments and add your [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Use a reaction in place of a "+1" comment: - -* 👍 - upvote -* 👎 - downvote - -If you cannot find an existing issue that describes your bug or feature, create a new issue using the guidelines below. - -### Writing Good Bug Reports and Feature Requests -File a single issue per problem and feature request. Do not enumerate multiple bugs or feature requests in the same issue. - -Do not add your issue as a comment to an existing issue unless it's for the identical input. Many issues look similar, but have different causes. - -The more information you can provide, the more likely someone will be successful at reproducing the issue and finding a fix. - -Please include the following with each issue: -- Current version of the package -- Target platform info (name and version) -- IDE you use -- Reproducible steps (1... 2... 3...) that cause the issue -- What you expected to see, versus what you actually saw -- Images, animations, or a link to a video showing the issue occurring - -### Final Checklist -Please remember to do the following: -- [X] Search the issue repository to ensure your report is a new issue -- [X] Separate issues reports -- [X] Include as much information as you can to your report - -Don't feel bad if the developers can't reproduce the issue right away. They will simply ask for more information! - -### Follow Your Issue -Once your report is submitted, be sure to stay in touch with the devs in case they need more help from you. - -## Contributing to codebase -If you are interested in writing code to fix issues or implement new awesome features you can follow this guidelines to get a better result - -### Build and run project -1. Clone repository to local storage using [Git command prompt](https://guides.github.com/introduction/git-handbook/) or [Visual Studio](https://docs.microsoft.com/en-us/visualstudio/get-started/tutorial-open-project-from-repo?view=vs-2019) - - Git clone command: - ``` - git clone https://github.com/xfox111/SimpleOTP.git - ``` -2. Open `SimpleOTP.sln` using [Microsoft Visual Studio](https://visualstudio.microsoft.com/) 2019 or later, or open repository folder with [Visual Studio Code](https://code.visualstudio.com/) (or with any other tool you use) - - Make sure you have properly installed and congigured [.NET 5 SDK](https://dotnet.microsoft.com/) -3. Press "Build Soulution" in Visual Studio or run `dotnet build` command from a terminal prompt if you are using VS Code -4. Open "Test Explorer" in VS or run `dotnet test` in VS Code to run unit tests - -### Development workflow -This section represents how contributors should interact with codebase implementing features and fixing bugs -1. Getting assigned to the issue -2. Creating a repository fork -3. Making changes to the codebase -5. Creating a pull request to `master` -6. Code review -7. Completing PR -8. Creating a release -9. Done! - -### Coding guidelines -#### Indentation -We use tabs, not spaces. - -#### Names -The project naming rules inherit [.NET Naming Guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/naming-guidelines). Nevertheless there're some distinctions with the guidelines as well as additions to those ones: -- Use `camelCase` for fields instead of `CamelCase` stated in [Capitalization Conventions](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/capitalization-conventions#capitalization-rules-for-identifiers) -- Private fields for properties should always start with underscore `_` - - Wrong: - ``` - private int year = 1984; - public int Year - { - get => year; - set => year = value; - } - ``` - - Correct: - ``` - private int _year = 1984; - public int Year - { - get => _year; - set => _year = value; - } - ``` -> **Note:** underscores `_` before generic **private** fields are allowed but not recommended -- Use `PascalCase` for file names - -#### Comments -Read [XML documentation comments](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/) and try to use all stated methods. Remember: the more detailed documentation your code has the less programmers will curse you in the future - -#### Strings -Use "double quotes" wherever it's possible -#### Style -- Prefer to use lambda functions - - Wrong: - ``` - button.Click += Button_Click; - ... - private void Button_Click (object sender, RoutedEventArgs e) - { - Console.WriteLine("Hello, World!"); - } - ``` - ``` - public void Main () - { - Console.WriteLine("Hello, World!"); - } - ``` - - Correct: - ``` - button.Click += (s, e) => Console.WriteLine("Hello, World!"); - ``` - ``` - public void Main () => - Console.WriteLine("Hello, World!"); - ``` -- Put curly braces on new lines - - Wrong: - ``` - if (condition) { - ... - } - ``` - - Correct: - ``` - if (condition) - { - ... - } - ``` -- Put spaces between operators and before braces in methods declarations, conditionals and loops - - Wrong: - - `y=k*x+b` - - `public void Main()` - - Correct: - - `y = k * x + b` - - `public void Main ()` -- Put `private` keyword even though it's unnecessary - - Wrong: `void Main ()` - - Correct: `private void Main ()` -- Use interpolated strings and ternary conditionals wherever it's possible - - Wrong: - - `string s = a + "; " + b`, `string s = string.Format("{0}; {1:00}", a, b)` - - ``` - string s; - if (condition) - s = "Life"; - else - s = "Death" - ``` - - Correct: - - `string s = $"{a}; {b:00}"` - - `string s = condition ? "Life" : "Death"` -- Do not surround loop and conditional bodies with curly braces if they can be avoided - - Wrong: - ``` - if (condition) - { - Console.WriteLine("Hello, World!"); - } - else - { - return; - } - ``` - - Correct - ``` - if (condition) - Console.WriteLine("Hello, World!"); - else - return; - ``` -- Use `#region` tags to separate code blocks (e.g. properties, methods, contructors, etc.) - -### Finding an issue to work on -Check out the [full issues list](https://github.com/XFox111/GUTSchedule/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue) for a list of all potential areas for contributions. **Note** that just because an issue exists in the repository does not mean we will accept a contribution to the core editor for it. There are several reasons we may not accept a pull request like: - -- Performance - One of the project's core values is to deliver a lightweight librayr, that means it should perform well in both real and perceived performance. -- Architectural - Feature owner needs to agree with any architectural impact a change may make. Such things must be discussed with and agreed upon by the feature owner. - -To improve the chances to get a pull request merged you should select an issue that is labelled with the `help-wanted` or `bug` labels. If the issue you want to work on is not labelled with `help-wanted` or `bug`, you can start a conversation with the issue owner asking whether an external contribution will be considered. - -To avoid multiple pull requests resolving the same issue, let others know you are working on it by saying so in a comment. - -### Submitting pull requests -To enable us to quickly review and accept your pull requests, always create one pull request per issue and [link the issue in the pull request](https://github.com/blog/957-introducing-issue-mentions). Never merge multiple requests in one unless they have the same root cause. Be sure to follow our [Coding Guidelines](#coding-guidelines) and keep code changes as small as possible. Avoid pure formatting changes to code that has not been modified otherwise. Pull requests should contain tests whenever possible. Fill pull request content according to its template. Deviations from template are not recommended - -#### Spell check errors -Pull requests that fix spell check errors are welcomed but please make sure it doesn't touch multiple feature areas, otherwise it will be difficult to review. - -## Thank You! -Your contributions to open source, large or small, make great projects like this possible. Thank you for taking the time to contribute. - -## Attribution -This Contribution Guidelines are adapted from the [Contributing to VS Code](https://github.com/microsoft/vscode/blob/master/CONTRIBUTING.md) \ No newline at end of file +This article has been moved to the [project's Wiki section](https://github.com/XFox111/SimpleOTP/wiki/Contribution-Guidelines) diff --git a/LICENSE b/LICENSE index 174be35..7452667 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Eugene Fox +Copyright (c) 2024 Eugene Fox Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 028d41c..16d6468 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,42 @@ -# SimpleOTP -[![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/SimpleOTP)](https://github.com/xfox111/SimpleOTP/commits/master) -[![MIT License](https://img.shields.io/github/license/xfox111/SimpleOTP)](https://opensource.org/licenses/MIT) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/xfox111/SimpleOTP)](https://github.com/xfox111/SimpleOTP/releases/latest) +[![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/SimpleOTP?label=Last+update)](https://github.com/XFox111/SimpleOTP/commits/main) -[![Twitter Follow](https://img.shields.io/twitter/follow/xfox111?style=social)](https://twitter.com/xfox111) -[![GitHub followers](https://img.shields.io/github/followers/xfox111?label=Follow%20@xfox111&style=social)](https://github.com/xfox111) -[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-%40xfox111-orange)](https://buymeacoffee.com/xfox111) +![SimpleOTP](https://cdn.xfox111.net/projects/simple-otp/SimpleOTP.svg) -.NET library for TOTP/HOTP implementation on server (ASP.NET) or client (Xamarin) side +Feature-rich and flexible .NET library for implementation of OTP authenticators and validatiors. ## Features -- Generate and validate OTP codes -- Support of [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_password) (RFC 6238) and [HOTP](https://en.wikipedia.org/wiki/HMAC-based_one-time_password) (RFC 4226) algorithms -- Support of HMAC-SHA1, HMAC-SHA256 and HMAC-SHA512 hashing algorithms -- Setup URI parser -- Database-ready configuration models -- Configuration generator for server-side implementation -- QR code generator -- No dependencies - -![By Mateusz Adamowski, taken with Canon EOS. - Own work, CC BY-SA 1.0, https://commons.wikimedia.org/w/index.php?curid=142232](https://upload.wikimedia.org/wikipedia/commons/3/33/RSA-SecurID-Tokens.jpg) -##### By Mateusz Adamowski, taken with Canon EOS. - Own work, CC BY-SA 1.0, https://commons.wikimedia.org/w/index.php?curid=142232 - -## Usage -See more documentation at [project's wiki](https://github.com/xfox111/SimpleOTP/wiki) -### Generate code -```csharp -string sample_config_uri = "otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio"; -OTPConfiguration config = OTPConfiguration.GetConfiguration(sample_config_uri); -// OTPConfiguration { Id = af2358b0-3f69-4dd7-9537-32c07d6663aa, Type = TOTP, IssuerLabel = FoxDev Studio, AccountName = eugene@xfox111.net, Secret = ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH, Issuer = FoxDev Studio, Algorithm = SHA1, Digits = 6, Counter = 0, Period = 00:00:30 } - - -OTPCode code = OTPService.GenerateCode(ref config); -// OTPasswordModel { Code = 350386, Expiring = 23-May-21 06:08:30 PM } -``` - -### Validate code -```csharp -int codeToValidate = 350386; -bool isValid = OTPService.ValidateTotp(codeToValidate, config, TimeSpan.FromSeconds(30)); // True -``` - -### Generate setup config -```csharp -OTPConfiguration config = OTPConfiguration.GenerateConfiguration("FoxDev Studio", "eugene@xfox111.net"); -// OTPModel { Id = af2358b0-3f69-4dd7-9537-32c07d6663aa, Type = TOTP, IssuerLabel = FoxDev Studio, AccountName = eugene@xfox111.net, Secret = ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH, Issuer = FoxDev Studio, Algorithm = SHA1, Digits = 6, Counter = 0, Period = 00:00:30 } - -Uri uri = config.GetUri(); // otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio -string qrCode = config.GetQrImage(300); // data:image/png;base64,... -``` - -### Streamline code generation for client -```csharp -OTPFactory factory = new (config); - -factory.CodeUpdated += (newCode) => Console.WriteLine(newCode); -// OTPCode { Code = 350386, Expiring = 23-May-21 06:08:30 PM } -factory.PropertyChanged += (sender, args) => -{ - if (args.PropertyName == nameof(factory.TimeLeft)) - Console.WriteLine(factory.TimeLeft); - else - Console.WriteLine(factory.Code); -} -... -factory.Dispose(); - -``` +- Full support for Time-based OTP generation and validation ([RFC 6238][RFC-6238]) +- Full support for HMAC-based OTP generation and validation ([RFC 4226][RFC-4226]) +- Ability to create `otpauth:` confguration URIs with full compliance with [Usage specification of the otpauth URI format for TOTP and HOTP token generators Internet-Draft][otpauth-ID] by I. Y. Eroglu +- Built-in `otpauth:` URI formatters to comply with different specifications (Apple, Google, IBM, and more) +- Fluent API support +- Supplementary `DependencyInjection` package for easier implementation in ASP.NET +- Continuous support of current and upcoming .NET versions +- And more! ## Download -![Nuget](https://img.shields.io/nuget/v/SimpleOTP) -![Nuget](https://img.shields.io/nuget/dt/SimpleOTP) -- [NuGet Gallery](https://www.nuget.org/packages/SimpleOTP) -- [GitHub Releases](https://github.com/xfox111/SimpleOTP/releases/latest) -## CI/DC status -[![Build Status](https://dev.azure.com/xfox111/GitHub%20CI/_apis/build/status/XFox111.SimpleOTP?branchName=master)](https://dev.azure.com/xfox111/GitHub%20CI/_build/latest?definitionId=13) -![Deployment status](https://vsrm.dev.azure.com/xfox111/_apis/public/Release/badge/e42c572c-a3cd-4aac-bbb1-f720d9ccb5ea/3/15) +| Package | Info | Download | +| --- | --- | --- | +| `EugeneFox.SimpleOTP` | [![Nuget](https://img.shields.io/nuget/v/EugeneFox.SimpleOTP)][nuget]
[![Nuget](https://img.shields.io/nuget/dt/EugeneFox.SimpleOTP)][nuget] | [NuGet Gallery][nuget]
[GitHub Releases](https://github.com/xfox111/SimpleOTP/releases/latest) | +| `EugeneFox.SimpleOTP.DependencyInjection` | [![Nuget](https://img.shields.io/nuget/v/EugeneFox.SimpleOTP.DependencyInjection)][nuget-di]
[![Nuget](https://img.shields.io/nuget/dt/EugeneFox.SimpleOTP.DependencyInjection)][nuget-di] | [NuGet Gallery][nuget-di]
[GitHub Releases](https://github.com/xfox111/SimpleOTP/releases/latest) | -![Azure DevOps tests](https://img.shields.io/azure-devops/tests/xfox111/GitHub%2520CI/13?label=Tests) -![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/xfox111/GitHub%2520CI/13?label=Code+coverage) +Use these commands to install SimpleOTP package in your project: +```bash +# For common projects: +dotnet add package EugeneFox.SimpleOTP +# Or for ASP.NET projects: +dotnet add package EugeneFox.SimpleOTP.DependencyInjection +``` + +## Usage, examples and docs + +Please refer to [project's Wiki](https://github.com/XFox111/SimpleOTP/wiki) for usage examples, API reference and other documentation. ## Contributing [![GitHub issues](https://img.shields.io/github/issues/xfox111/SimpleOTP)](https://github.com/xfox111/SimpleOTP/issues) +[![CI](https://github.com/XFox111/SimpleOTP/actions/workflows/cd_pipeline.yaml/badge.svg)](https://github.com/XFox111/SimpleOTP/actions/workflows/cd_pipeline.yaml) [![GitHub repo size](https://img.shields.io/github/repo-size/xfox111/SimpleOTP?label=repo%20size)](https://github.com/xfox111/SimpleOTP) There are many ways in which you can participate in the project, for example: @@ -89,18 +44,19 @@ There are many ways in which you can participate in the project, for example: - Review [source code changes](https://github.com/xfox111/SimpleOTP/pulls) - Review documentation and make pull requests for anything from typos to new content -If you are interested in fixing issues and contributing directly to the code base, please see the [Contribution Guidelines](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md), which covers the following: -- [How to deploy the extension on your browser](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#deploy-test-version-on-your-browser) -- [The development workflow](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#development-workflow), including debugging and running tests -- [Coding guidelines](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#coding-guidelines) -- [Submitting pull requests](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#submitting-pull-requests) -- [Finding an issue to work on](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#finding-an-issue-to-work-on) -- [Contributing to translations](https://github.com/XFox111/SimpleOTP/blob/master/CONTRIBUTING.md#contributing-to-translations) +If you are interested in fixing issues and contributing directly to the code base, please refer to the [Contribution Guidelines](https://github.com/XFox111/SimpleOTP/wiki/Contribution-Guidelines) -## Code of Conduct -This project has adopted the Contributor Covenant. For more information see the [Code of Conduct](https://github.com/XFox111/SimpleOTP/blob/master/CODE_OF_CONDUCT.md) +--- -## Copyrights -> ©2021 Eugene Fox +[![Twitter Follow](https://img.shields.io/twitter/follow/xfox111?style=social)](https://twitter.com/xfox111) +[![GitHub followers](https://img.shields.io/github/followers/xfox111?label=Follow%20@xfox111&style=social)](https://github.com/xfox111) +[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-%40xfox111-orange)](https://buymeacoffee.com/xfox111) -Licensed under [MIT License](https://opensource.org/licenses/MIT) +> ©2024 Eugene Fox. Licensed under [MIT license][mit] + +[RFC-6238]: https:// +[RFC-4226]: https:// +[otpauth-ID]: https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html +[nuget]: https://www.nuget.org/packages/EugeneFox.SimpleOTP +[nuget-di]: https://www.nuget.org/packages/EugeneFox.SimpleOTP.DependencyInjection +[mit]: https://github.com/XFox111/SimpleOTP/blob/main/LICENSE diff --git a/SECURITY.md b/SECURITY.md index 639536b..c92a4f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,20 @@ # Security Policy ## Supported Versions -| .NET Version | Supported | -| ----------------- | ------------------ | -| 5.0+ | :white_check_mark: | -| Core 3.1+ | :white_check_mark: | -| Standard 2.1+ | :white_check_mark: | -| Any older version | :x: | -### End of support -After release of new version of framework previous comes out of support in 3 months +SimpleOTP packages have their versioning system in sync with .NET versions. That means that for each active .NET version there will be a corresponding major version of the packages. + +We regularly run security audits and fix any security issues that are found. If you find a security issue, please report it to us as described below. + +## .NET support + +We support all active .NET versions until the end of their lifecycle. We do not support .NET versions that reached their end of support. That means that at a time there're can be at max three versions of .NET that are being supported (LTS, active, and preview or next LTS). + +> [!IMPORTANT] +Support of .NET preview versions is not guaranteed and is to be announced. + +> [!IMPORTANT] +.NET Core, .NET Standard and other .NET editions existing prior to .NET 5 are not and will not be supported ## Reporting a Vulnerability If you found any vulnerability, please tell us on opensource@xfox111.net. You'll get all updates on a reported issue, unless you stated it in the message diff --git a/SimpleOTP.Test/GlobalSuppressions.cs b/SimpleOTP.Test/GlobalSuppressions.cs deleted file mode 100644 index ded9300..0000000 --- a/SimpleOTP.Test/GlobalSuppressions.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -/* - * This file is used by Code Analysis to maintain SuppressMessage - * attributes that are applied to this project. - * Project-level suppressions either have no target or are given - * a specific target and scoped to a namespace, type, member, etc. -*/ - -using System.Diagnostics.CodeAnalysis; - -[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:Braces should not be omitted", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] -[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1625:Element documentation should not be copied and pasted", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] -[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1519:Braces should not be omitted from multi-line child statement", Justification = "Reviewd by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] -[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "Reviewd by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] -[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] -[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP.Test")] \ No newline at end of file diff --git a/SimpleOTP.Test/Helpers/Base32UnitTest.cs b/SimpleOTP.Test/Helpers/Base32UnitTest.cs deleted file mode 100644 index aac23d2..0000000 --- a/SimpleOTP.Test/Helpers/Base32UnitTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Text; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Helpers; - -namespace SimpleOTP.Test.Helpers -{ - /// - /// Unit-tests for Base32 encoder. - /// - [TestClass] - public class Base32UnitTest - { - /// - /// Test encoder with byte array. - /// - [TestMethod("Byte array Base32 encoder test")] - public void EncoderTest() - { - byte[] bytes = new byte[new Random().Next(16, 20)]; - new Random().NextBytes(bytes); - string str = Base32Encoder.Encode(bytes); - - byte[] result = Base32Encoder.Decode(str); - Assert.AreEqual(bytes.Length, result.Length); - for (int i = 0; i < bytes.Length; i++) - Assert.AreEqual(bytes[i], result[i]); - } - - /// - /// Test encoder with string content. - /// - [TestMethod("String Base32 encoder test")] - public void EncoderStringTest() - { - string testStr = "Hello, World!"; - string encodedStr = Base32Encoder.Encode(Encoding.UTF8.GetBytes(testStr)); - - byte[] resultBytes = Base32Encoder.Decode(encodedStr); - string result = Encoding.UTF8.GetString(resultBytes); - Assert.AreEqual(testStr, result); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/Helpers/SecretGeneratorUnitTest.cs b/SimpleOTP.Test/Helpers/SecretGeneratorUnitTest.cs deleted file mode 100644 index 6e71d58..0000000 --- a/SimpleOTP.Test/Helpers/SecretGeneratorUnitTest.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Helpers; - -namespace SimpleOTP.Test.Helpers -{ - /// - /// Unit-tests for key generator. - /// - [TestClass] - public class SecretGeneratorUnitTest - { - /// - /// Overall test of key generator. - /// - [TestMethod("Overall generator tests")] - public void Test_Generator() - { - Assert.ThrowsException(() => SecretGenerator.GenerateSecret(64)); - Assert.ThrowsException(() => SecretGenerator.GenerateSecret(256)); - - string key = SecretGenerator.GenerateSecret(); - Assert.IsFalse(string.IsNullOrWhiteSpace(key)); - - key = SecretGenerator.GenerateSecret(128); - Assert.IsFalse(string.IsNullOrWhiteSpace(key)); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/Helpers/UriParserUnitTest.cs b/SimpleOTP.Test/Helpers/UriParserUnitTest.cs deleted file mode 100644 index cc9e4aa..0000000 --- a/SimpleOTP.Test/Helpers/UriParserUnitTest.cs +++ /dev/null @@ -1,160 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Models; - -namespace SimpleOTP.Test.Helpers -{ - /// - /// Unit-tests for OTP URI parser. - /// - [TestClass] - public class UriParserUnitTest - { - private static readonly Guid TestGuid = Guid.NewGuid(); - - private readonly string[] uriParts = - { - "otpauth", - "totp|hotp", - "FoxDev%20Studio", - "eugene@xfox111.net", - "secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", - "issuer=FoxDev%20Studio%20Issuer", - "algorithm=SHA1|algorithm=SHA512", - "digits=8", - "period=10", - "counter=10000", - }; - - private readonly OTPConfiguration[] configs = - { - new () - { - AccountName = "eugene@xfox111.net", - Algorithm = Enums.Algorithm.SHA512, - Digits = 8, - Type = Enums.OTPType.TOTP, - Secret = "ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", - Issuer = "FoxDev Studio Issuer", - IssuerLabel = "FoxDev Studio", - Id = TestGuid, - Period = TimeSpan.FromSeconds(10), - }, - new () - { - AccountName = "eugene@xfox111.net", - Algorithm = Enums.Algorithm.SHA512, - Digits = 8, - Type = Enums.OTPType.HOTP, - Secret = "ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", - Issuer = "FoxDev Studio Issuer", - IssuerLabel = "FoxDev Studio", - Id = TestGuid, - Counter = 10000, - }, - new () - { - AccountName = "eugene@xfox111.net", - Secret = "ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", - Issuer = "FoxDev Studio", - IssuerLabel = "FoxDev Studio", - Id = TestGuid, - }, - new () - { - AccountName = "eugene@xfox111.net", - Type = Enums.OTPType.HOTP, - Secret = "ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", - Issuer = "FoxDev Studio", - IssuerLabel = "FoxDev Studio", - Id = TestGuid, - Counter = 10000, - }, - }; - - /// - /// Test parser with full TOTP URI. - /// - [TestMethod("Valid full URI (TOTP)")] - public void ParseValidUri_FullFormed_Totp() - { - string uri = $"{uriParts[0]}://{uriParts[1].Split('|')[0]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[5]}&{uriParts[6].Split('|')[1]}&{uriParts[7]}&{uriParts[8]}"; - var config = SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri)); - Assert.IsNotNull(config); - config.Id = TestGuid; - Assert.AreEqual(configs[0], config); - } - - /// - /// Test parser with full HOTP URI. - /// - [TestMethod("Valid full URI (HOTP)")] - public void ParseValidUri_FullFormed_Hotp() - { - string uri = $"{uriParts[0]}://{uriParts[1].Split('|')[1]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[5]}&{uriParts[6].Split('|')[1]}&{uriParts[7]}&{uriParts[9]}"; - var config = SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri)); - Assert.IsNotNull(config); - config.Id = TestGuid; - Assert.AreEqual(configs[1], config); - } - - /// - /// Test parser with TOTP URI. Minimal parameter set. - /// - [TestMethod("Valid minimal URI (TOTP)")] - public void ParseValidUri_Minimal_Totp() - { - string uri = $"{uriParts[0]}://{uriParts[1].Split('|')[0]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}"; - var config = SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri)); - Assert.IsNotNull(config); - config.Id = TestGuid; - Assert.AreEqual(configs[2], config); - - config = SimpleOTP.Helpers.UriParser.ParseUri(new Uri($"otpauth://totp/eugene@xfox111.net?{uriParts[4]}&{uriParts[5]}&algorithm=SHA256")); - Assert.IsNotNull(config); - config.Id = TestGuid; - Assert.AreEqual(configs[2] with { IssuerLabel = "FoxDev Studio Issuer", Issuer = "FoxDev Studio Issuer", Algorithm = Enums.Algorithm.SHA256 }, config); - } - - /// - /// Test parser with HOTP URI. Minimal parameter set. - /// - [TestMethod("Valid minimal URI (HOTP)")] - public void ParseValidUri_Minimal_Hotp() - { - string uri = $"{uriParts[0]}://{uriParts[1].Split('|')[1]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[9]}"; - var config = SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri)); - Assert.IsNotNull(config); - config.Id = TestGuid; - Assert.AreEqual(configs[3], config); - } - - /// - /// Test parser with invalid OTP URIs. - /// - [TestMethod("Invalid URI")] - public void ParseInvalidUris() - { - string[] uris = - { - $"https://{uriParts[1].Split('|')[1]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[9]}", - $"{uriParts[0]}://otp/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[9]}", - $"{uriParts[0]}://{uriParts[2]}:{uriParts[3]}?{uriParts[4]}&{uriParts[9]}", - $"{uriParts[0]}://{uriParts[1].Split('|')[0]}/{uriParts[3]}?{uriParts[4]}&{uriParts[9]}", - $"{uriParts[0]}://{uriParts[1].Split('|')[0]}/?{uriParts[4]}&{uriParts[9]}", - $"{uriParts[0]}://{uriParts[1].Split('|')[0]}/{uriParts[2]}:{uriParts[3]}", - $"{uriParts[0]}://{uriParts[1].Split('|')[1]}/{uriParts[2]}:{uriParts[3]}?{uriParts[4]}", - }; - foreach (string uri in uris) - Assert.ThrowsException(() => SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri))); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/Models/OTPCodeUnitTest.cs b/SimpleOTP.Test/Models/OTPCodeUnitTest.cs deleted file mode 100644 index 471e149..0000000 --- a/SimpleOTP.Test/Models/OTPCodeUnitTest.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Models; - -namespace SimpleOTP.Test.Models -{ - /// - /// Unit-tests for OTP code model. - /// - [TestClass] - public class OTPCodeUnitTest - { - /// - /// Get formatted OTP code. - /// - [TestMethod("Code formatting")] - public void Test_GetFullCode() - { - OTPCode code = new () - { - Code = 123, - Expiring = DateTime.UtcNow.AddSeconds(30), - }; - - Assert.AreEqual("000123", code.GetCode()); - Assert.AreEqual("000 123", code.GetCode("000 000")); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/Models/OTPConfigurationUnitTest.cs b/SimpleOTP.Test/Models/OTPConfigurationUnitTest.cs deleted file mode 100644 index 99ec584..0000000 --- a/SimpleOTP.Test/Models/OTPConfigurationUnitTest.cs +++ /dev/null @@ -1,90 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Threading.Tasks; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Models; - -namespace SimpleOTP.Test.Models -{ - /// - /// Unit-tests for OTP generator configuration model. - /// - [TestClass] - public class OTPConfigurationUnitTest - { - /// - /// Get otpauth link from minimal configuration. - /// - [TestMethod("Link generator (short)")] - public void TestShortLinkGenerator() - { - OTPConfiguration config = OTPConfiguration.GenerateConfiguration("FoxDev Studio", "eugene@xfox111.net"); - var testId = config.Id; - System.Diagnostics.Debug.WriteLine(testId); - Uri uri = config.GetUri(); - Assert.AreEqual($"otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret={config.Secret}&issuer=FoxDev%20Studio", uri.AbsoluteUri); - } - - /// - /// Get otpauth link from complete configurations (TOTP + HOTP). - /// - [TestMethod("Link generator (full)")] - public void TestFullLinkGenerator() - { - OTPConfiguration config = OTPConfiguration.GetConfiguration("otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&period=10"); - Assert.AreEqual($"otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&period=10", config.GetUri().AbsoluteUri); - config.Type = Enums.OTPType.HOTP; - Assert.AreEqual($"otpauth://hotp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&counter=0", config.GetUri().AbsoluteUri); - } - - /// - /// Test user-friendly secret formatting. - /// - [TestMethod("Fancy secret")] - public void GetFormattedSecret() - { - OTPConfiguration config = OTPConfiguration.GetConfiguration("ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", "FoxDev Studio", "eugene@xfox111.net"); - Assert.AreEqual($"ESQV TYRM 2CWZ C3NX 24GR RWIA UUWV HWQH", config.GetFancySecret()); - } - - /// - /// Test QR code generation for OTP config. - /// - /// . - [TestMethod("QR code image")] - public async Task GetQrImage() - { - OTPConfiguration config = OTPConfiguration.GetConfiguration("ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", "FoxDev Studio", "eugene@xfox111.net"); - string imageStr = await config.GetQrImage(); - Assert.AreEqual( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAIAAAD2HxkiAAAABmJLR0QA/wD/AP+gvaeTAAAHP0lEQVR4nO3dwW5TOxRA0faJ//9l3ox" + - "JJGQux95OWGvcJiF0y9KRr/398+fPL6DzX/0B4F8nQoiJEGIihJgIISZCiIkQYiKEmAghJkKIiRBiIoSYCCEmQoiJEGIihJgIISZCiIkQYiKEmAghJkKIiRBiIoSY" + - "CCEmQoj9GH/F7+/v8ddc93qq/+vnOfkzK1ZuItj3rU7dgzD1b9/3PU8ZvznCSggxEUJMhBATIcRECLH56eirffeQrkzJns3WpuZvK+++Mg88OcN89q0+m3NOaf/G/" + - "pKVEGIihJgIISZCiIkQYiemo6/27bF89l7Pdm/etlN033xy32/tm6Ce/Bv7S1ZCiIkQYiKEmAghJkKINdPR25ycc56cNO4zNXPmy0oIORFCTIQQEyHERAgx09FVU1" + - "PN9szMk0xQF1kJISZCiIkQYiKEmAgh1kxHT87Epu79ebYLdN/JqPte59l7rZj6Vle80dzVSggxEUJMhBATIcRECLET09F2t+TJ++hP3rc+NVfc9/2sfJ6p13nrHbl" + - "WQoiJEGIihJgIISZCiH2/0Ra7ffZN/267X2mfqV2yt/27DrASQkyEEBMhxEQIMRFCbH7v6NQuvqk7j/btKjy5X/G2+9+n9qk++5kPm0tbCSEmQoiJEGIihJgIITY/" + - "HW2fW1/xjjcltffRt9/zvte5hJUQYiKEmAghJkKIiRBiJ/aOTp0/ufJeK6+8z779rlMTwnf8xp791tS8/cC3YSWEmAghJkKIiRBiIoTYib2jr07uTrztVvR9z25PT" + - "aGfTVDbp/jf6Dn6V1ZCiIkQYiKEmAghJkKIzd/KdPL0yxVTs7WT+0JXXvnVvlnfvh2nJ08Zfbb/1nQUPp8IISZCiIkQYiKE2Pze0RVTT99POXkS6f1npe57Zv/V1M" + - "x539+GW5ng84kQYiKEmAghJkKInZiOnpzjtTcB7ds/+ex12v2uK+81NYm95Bn5Z6yEEBMhxEQIMRFCTIQQOzEdbfd87tt1OTVBvf/kgWfvtfIvbT/zJTNVKyHERAg" + - "xEUJMhBATIcTufbL+5J7PV/ue/T95Z/3J0zin7ojf91z/ydMJ/oiVEGIihJgIISZCiIkQYrc8WX//neOXTNJ+Y98EdeW3Tu4QfvYz+04e+EtWQoiJEGIihJgIISZC" + - "iM1PR9v9kyfnpe1s7eSez5P2PTX/jCfr4fOJEGIihJgIISZCiH2PD39OTtumZqEnd0LuO/X0mZNP+q9op77JSaRWQoiJEGIihJgIISZCiN17K1N7juWKqbnZbXPOZ" + - "55NoZ/9zMpvTX0ee0fh84kQYiKEmAghJkKI3XIr04p9s8f2Bqhn2pubnn2elVee2ik69X9h7yh8PhFCTIQQEyHERAixE+eO7puF3r93dGq21t5s1d7B9Ozz3H/Gwi" + - "9WQoiJEGIihJgIISZCiM1PR9tZ6MortzPVFSenf7ed6jn1OgemmlOshBATIcRECDERQkyEEJu/lemZqYnlvh2nJ/ey3vZ59jn553ftblIrIcRECDERQkyEEBMhxO4" + - "9d/S28zCfvfK+e3/u3y2577apqXd3KxPw9SVCyIkQYiKEmAghNr93dN/eyHaWdXLH6TP7pqz7JrG3TY+TvzErIcRECDERQkyEEBMhxJon60/uezw52dv33Ho72Tt5" + - "t9Srk/9fU7/1R6yEEBMhxEQIMRFCTIQQ+7RzR1+1s7WV11nRnhe6b4o4tXP15M+MsxJCTIQQEyHERAgxEULsxHS0vXFpRfvs9r98v9LUOQz7fusAKyHERAgxEUJMh" + - "BATIcROnDv6judhPtPOZqde+dl7Tb37yb+fS1gJISZCiIkQYiKEmAghdsu5oytOPsn+7N1P3srUzlTvv0VrxSUTVCshxEQIMRFCTIQQEyHEfhx4j6n55Mlp276pZn" + - "vuaLsP8/7TU1+5lQk+nwghJkKIiRBiIoTYLbcy7XPbFHHfrsvb7nu67cn6V5f88VsJISZCiIkQYiKEmAghNr939La7gW67u3zfez2bc578NvbtIl75PNc++28lhJg" + - "IISZCiIkQYiKE2Ikn6/fNl04+s//st97xdvWp01OfTapXnJxUH2AlhJgIISZCiIkQYiKE2Inp6KvbZmIr9s0MV7Q7V6emx8/mpfsmlu0+51+shBATIcRECDERQkyE" + - "EGumoyft2715crfk1LPkn2HfnVkrr+NWJvg0IoSYCCEmQoiJEGKfPx2dMjVBnXqvqfNC9z3Xv+/U0xWXPDW/wkoIMRFCTIQQEyHERAixZjp6257GffO3qRuOTu6Wn" + - "JrNtvPJ9iSEP2IlhJgIISZCiIkQYiKE2Inp6G179vZNNfc9R/9q6pb2qZ9pTxmd2qeasBJCTIQQEyHERAgxEULs+5IBEfyzrIQQEyHERAgxEUJMhBATIcRECDERQk" + - "yEEBMhxEQIMRFCTIQQEyHERAgxEUJMhBATIcRECDERQkyEEBMhxEQIMRFCTIQQEyHE/gcIt5Gg5RNZHAAAAABJRU5ErkJggg==", imageStr); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/OTPFactoryUnitTest.cs b/SimpleOTP.Test/OTPFactoryUnitTest.cs deleted file mode 100644 index 0582817..0000000 --- a/SimpleOTP.Test/OTPFactoryUnitTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Threading.Tasks; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Models; - -namespace SimpleOTP.Test -{ - /// - /// OTP factory class unit-tests. - /// - [TestClass] - public class OTPFactoryUnitTest - { - /// - /// Complex test of OTP factory. - /// - /// . - [TestMethod("Overall factory test")] - public async Task TestFactory() - { - OTPConfiguration config = OTPConfiguration.GetConfiguration("ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", "FoxDev Studio", "eugene@xfox111.net"); - config.Period = TimeSpan.FromSeconds(3); - using OTPFactory factory = new (config, 1500); - var testGetConfig = factory.Configuration; - System.Diagnostics.Debug.WriteLine(testGetConfig); - var code = factory.CurrentCode; - - factory.Configuration = config; - factory.CurrentCode = code; - - await Task.Delay(3500); - Assert.AreNotEqual(code.Code, factory.CurrentCode.Code); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/OTPServiceUnitTest.cs b/SimpleOTP.Test/OTPServiceUnitTest.cs deleted file mode 100644 index 36b7be2..0000000 --- a/SimpleOTP.Test/OTPServiceUnitTest.cs +++ /dev/null @@ -1,139 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SimpleOTP.Models; - -namespace SimpleOTP.Test -{ - /// - /// Unit-tests for OTP generator. - /// - [TestClass] - public class OTPServiceUnitTest - { - private readonly DateTime time = new (2021, 5, 28, 10, 47, 50, DateTimeKind.Utc); - private OTPConfiguration totpConfig = OTPConfiguration.GetConfiguration(new Uri("otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=FoxDev%20Studio")); - private OTPConfiguration hotpConfig = OTPConfiguration.GetConfiguration(new Uri("otpauth://hotp/FoxDev%20Studio:eugene@xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=FoxDev%20Studio&counter=10000")); - - /// - /// Test time-based OTP generator with pre-calculated code. - /// - [TestMethod("TOTP code generation")] - public void GenerateCode_Totp() - { - var config = totpConfig with { }; - var code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(160102, code.Code); - - config.Algorithm = Enums.Algorithm.SHA256; - code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(195671, code.Code); - - config.Algorithm = Enums.Algorithm.SHA512; - code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(293657, code.Code); - } - - /// - /// Test time-based OTP generator with customly formatted secret. - /// - [TestMethod("Secret format test")] - public void FormatTest() - { - Console.Write("Uppercase space-separated: "); - var config = totpConfig with { Secret = "JBSW Y3DP EHPK 3PXP" }; - var code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(160102, code.Code); - Console.WriteLine("Passed."); - - Console.Write("Lowercase space-separated: "); - config = totpConfig with { Secret = "jbsw y3dp ehpk 3pxp" }; - code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(160102, code.Code); - Console.WriteLine("Passed."); - - Console.Write("Lowercase: "); - config = totpConfig with { Secret = "jbswy3dpehpk3pxp" }; - code = OTPService.GenerateCode(ref config, time); - Assert.AreEqual(160102, code.Code); - Console.WriteLine("Passed."); - } - - /// - /// Test HOTP generator with pre-calculated code. - /// - [TestMethod("HOTP code generation")] - public void GenerateCode_Hotp() - { - var code = OTPService.GenerateCode(ref hotpConfig); - Assert.AreEqual(457608, code.Code); - Assert.AreEqual(10001, hotpConfig.Counter); - } - - /// - /// Test time-based OTP validator with series of codes. - /// - [TestMethod("TOTP code validation")] - public void ValidateCode_Totp() - { - int[] codes = - { - OTPService.GenerateCode(ref totpConfig, DateTime.UtcNow.AddSeconds(-45)).Code, - OTPService.GenerateCode(ref totpConfig, DateTime.UtcNow.AddSeconds(-15)).Code, - OTPService.GenerateCode(ref totpConfig, DateTime.UtcNow.AddSeconds(0)).Code, - OTPService.GenerateCode(ref totpConfig, DateTime.UtcNow.AddSeconds(15)).Code, - OTPService.GenerateCode(ref totpConfig, DateTime.UtcNow.AddSeconds(45)).Code, - }; - Assert.IsFalse(OTPService.ValidateTotp(codes[0], totpConfig)); - Assert.IsTrue(OTPService.ValidateTotp(codes[1], totpConfig)); - Assert.IsTrue(OTPService.ValidateTotp(codes[2], totpConfig)); - Assert.IsTrue(OTPService.ValidateTotp(codes[3], totpConfig)); - Assert.IsFalse(OTPService.ValidateTotp(codes[4], totpConfig)); - - Assert.IsTrue(OTPService.ValidateTotp(codes[0], totpConfig, TimeSpan.FromSeconds(60))); - - Assert.ThrowsException(() => OTPService.ValidateTotp(0, hotpConfig)); - } - - /// - /// Test HOTP validator with series of codes. - /// - [TestMethod("HOTP code validation")] - public void ValidateCode_Hotp() - { - hotpConfig.Counter = 10000; - int[] codes = - { - OTPService.GenerateCode(ref hotpConfig).Code, - OTPService.GenerateCode(ref hotpConfig).Code, - OTPService.GenerateCode(ref hotpConfig).Code, - OTPService.GenerateCode(ref hotpConfig).Code, - OTPService.GenerateCode(ref hotpConfig).Code, - }; - - hotpConfig.Counter = 10002; - Assert.IsFalse(OTPService.ValidateHotp(codes[0], ref hotpConfig, 1, true)); - Assert.AreEqual(10002, hotpConfig.Counter); - Assert.IsTrue(OTPService.ValidateHotp(codes[1], ref hotpConfig, 1, true)); - Assert.AreEqual(10001, hotpConfig.Counter); - hotpConfig.Counter = 10002; - Assert.IsTrue(OTPService.ValidateHotp(codes[2], ref hotpConfig, 1, true)); - Assert.AreEqual(10002, hotpConfig.Counter); - hotpConfig.Counter = 10002; - Assert.IsTrue(OTPService.ValidateHotp(codes[3], ref hotpConfig, 1, true)); - Assert.AreEqual(10003, hotpConfig.Counter); - hotpConfig.Counter = 10002; - Assert.IsFalse(OTPService.ValidateHotp(codes[4], ref hotpConfig, 1, true)); - Assert.AreEqual(10002, hotpConfig.Counter); - - Assert.ThrowsException(() => OTPService.ValidateHotp(0, ref totpConfig, 1, true)); - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Test/SimpleOTP.Test.csproj b/SimpleOTP.Test/SimpleOTP.Test.csproj deleted file mode 100644 index 7b2780f..0000000 --- a/SimpleOTP.Test/SimpleOTP.Test.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - net5.0 - SimpleOTP.Tests - SimpleOTP.Tests - - - - true - true - false - false - false - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - \ No newline at end of file diff --git a/SimpleOTP.Test/stylecop.json b/SimpleOTP.Test/stylecop.json deleted file mode 100644 index 3c594d2..0000000 --- a/SimpleOTP.Test/stylecop.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": - { - "documentationRules": - { - "companyName": "Eugene Fox", - "copyrightText": "------------------------------------------------------------\nCopyright ©{year} {companyName}. All rights reserved.\nCode by {author}\n\nLicensed under MIT license (https://opensource.org/licenses/MIT)\n------------------------------------------------------------", - "xmlHeader": false, - "variables": - { - "year": "2021", - "author": "Eugene Fox (aka XFox)" - }, - "headerDecoration": "------------------------------------------------------------", - "fileNamingConvention": "stylecop", - "documentationCulture": "en-US" - }, - "indentation": - { - "useTabs": true - }, - "layoutRules": - { - "newlineAtEndOfFile": "omit" - }, - "maintainabilityRules": - { - "topLevelTypes": [ "class", "interface", "struct" ] - }, - "namingRules": - { - "allowCommonHungarianPrefixes": false, - "allowedNamespaceComponents": [], - "tupleElementNameCasing": "PascalCase" - }, - "orderingRules": - { - "blankLinesBetweenUsingGroups": "require", - "systemUsingDirectivesFirst": true, - "usingDirectivesPlacement": "outsideNamespace" - }, - "readabilityRules": - { - "allowBuiltInTypeAliases": false - } - } -} \ No newline at end of file diff --git a/SimpleOTP.Tests/ConfigTests.cs b/SimpleOTP.Tests/ConfigTests.cs new file mode 100644 index 0000000..696252e --- /dev/null +++ b/SimpleOTP.Tests/ConfigTests.cs @@ -0,0 +1,66 @@ +using NUnit.Framework; + +namespace SimpleOTP.Tests; + +[TestFixture] +public class ConfigTests +{ + private static readonly byte[] secretBytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0xde, 0xad, 0xbe, 0xef]; + private const string username = "eugene@xfox111.net"; + private const string appName = "Example App"; + private const string issuer = "example.com"; + + + [TestCase("otpauth://totp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA1&digits=6&period=30", + "SHA1", OtpType.Totp, 6, 30, null)] + [TestCase("otpauth://totp/Example%20App:eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA1&digits=6&period=30", + "SHA1", OtpType.Totp, 6, 30, appName)] + [TestCase("apple-otpauth://totp/Example%20App:eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA1&digits=6&period=30", + "SHA1", OtpType.Totp, 6, 30, appName)] + [TestCase("otpauth://totp/Example%20App:eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA256&digits=8&period=60", + "SHA256", OtpType.Totp, 8, 60, appName)] + [TestCase("otpauth://totp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA512&digits=6&period=30", + "SHA512", OtpType.Totp, 6, 30, null)] + [TestCase("otpauth://hotp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA512&digits=6&counter=0", + "SHA512", OtpType.Hotp, 6, 30, null)] + [TestCase("otpauth://hotp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=HmacSHA512&digits=6&counter=0", + "SHA512", OtpType.Hotp, 6, 30, null)] + public void ParseTest(string uri, string algorithm, OtpType type, int digits, int period, string? appName) + { + OtpConfig config = OtpConfig.ParseUri(uri); + + Assert.That(config.Label, Is.EqualTo(username)); + Assert.That(config.Secret, Is.EqualTo(secretBytes)); + Assert.That(config.Issuer, Is.EqualTo(issuer)); + Assert.That(config.IssuerLabel, Is.EqualTo(appName)); + Assert.That(config.Algorithm, Is.EqualTo(algorithm)); + Assert.That(config.Digits, Is.EqualTo(digits)); + Assert.That(config.Period, Is.EqualTo(period)); + Assert.That(config.Type, Is.EqualTo(type)); + } + + [TestCase(OtpUriFormat.Google | OtpUriFormat.Minimal, false, + "otpauth://totp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com")] + [TestCase(OtpUriFormat.Google | OtpUriFormat.Full, false, + "otpauth://totp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA1&digits=6&period=30")] + [TestCase(OtpUriFormat.Apple | OtpUriFormat.Minimal, true, + "apple-otpauth://totp/Example%20App:eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com")] + [TestCase(OtpUriFormat.Apple | OtpUriFormat.Full, true, + "apple-otpauth://totp/Example%20App:eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=SHA1&digits=6&period=30")] + [TestCase(OtpUriFormat.IBM | OtpUriFormat.Full, false, + "otpauth://totp/eugene%40xfox111.net?secret=JBSWY3DPEHPK3PXP&issuer=example.com&algorithm=HmacSHA1&digits=6&period=30")] + public void ToUriTest(OtpUriFormat format, bool appendIssuerLabel, string expectedUri) + { + OtpConfig config = new(username) + { + Issuer = issuer, + Secret = OtpSecret.FromBytes(secretBytes) + }; + + if (appendIssuerLabel) + config.IssuerLabel = appName; + + Uri uri = config.ToUri(format); + Assert.That(uri.AbsoluteUri, Is.EqualTo(expectedUri)); + } +} diff --git a/SimpleOTP.Tests/SimpleOTP.Tests.csproj b/SimpleOTP.Tests/SimpleOTP.Tests.csproj new file mode 100644 index 0000000..ce29b71 --- /dev/null +++ b/SimpleOTP.Tests/SimpleOTP.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + diff --git a/SimpleOTP.Tests/TotpTests.cs b/SimpleOTP.Tests/TotpTests.cs new file mode 100644 index 0000000..c955af4 --- /dev/null +++ b/SimpleOTP.Tests/TotpTests.cs @@ -0,0 +1,63 @@ +using NUnit.Framework; + +namespace SimpleOTP.Tests; + +[TestFixture] +public class TotpTests +{ + private const string secret1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + private const string secret2 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA"; + private const string secret3 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA"; + + [TestCase(secret1, "SHA1", 59, "94287082", 60)] + [TestCase(secret2, "SHA256", 59, "46119246", 60)] + [TestCase(secret3, "SHA512", 59, "90693936", 60)] + [TestCase(secret1, "SHA1", 1111111109, "07081804", 1111111110)] + [TestCase(secret2, "SHA256", 1111111109, "68084774", 1111111110)] + [TestCase(secret3, "SHA512", 1111111109, "25091201", 1111111110)] + [TestCase(secret1, "SHA1", 1111111111, "14050471", 1111111140)] + [TestCase(secret2, "SHA256", 1111111111, "67062674", 1111111140)] + [TestCase(secret3, "SHA512", 1111111111, "99943326", 1111111140)] + [TestCase(secret1, "SHA1", 1234567890, "89005924", 1234567920)] + [TestCase(secret2, "SHA256", 1234567890, "91819424", 1234567920)] + [TestCase(secret3, "SHA512", 1234567890, "93441116", 1234567920)] + [TestCase(secret1, "SHA1", 2000000000, "69279037", 2000000010)] + [TestCase(secret2, "SHA256", 2000000000, "90698825", 2000000010)] + [TestCase(secret3, "SHA512", 2000000000, "38618901", 2000000010)] + [TestCase(secret1, "SHA1", 20000000000, "65353130", 20000000010)] + [TestCase(secret2, "SHA256", 20000000000, "77737706", 20000000010)] + [TestCase(secret3, "SHA512", 20000000000, "47863826", 20000000010)] + [TestCase(secret1, "SHA1", 20000000000, "353130", 20000000010)] + [TestCase(secret2, "SHA256", 20000000000, "737706", 20000000010)] + [TestCase(secret3, "SHA512", 20000000000, "863826", 20000000010)] + public void ComputeTest(string secret, string algorithm, long timestamp, string expectedOtp, long expectedExpiration) + { + using OtpSecret otpSecret = OtpSecret.Parse(secret); + Totp totp = new(otpSecret, 30, (OtpAlgorithm)algorithm, expectedOtp.Length); + DateTimeOffset time = DateTimeOffset.FromUnixTimeSeconds(timestamp); + OtpCode code = totp.Generate(time); + + Assert.That(code.ToString(), Is.EqualTo(expectedOtp)); + Assert.That(code.ExpirationTime, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(expectedExpiration))); + } + + [TestCase(59, "287082", 1, true, 0)] + [TestCase(0, "287082", 1, true, 1)] + [TestCase(10, "287082", 0, false, 0)] + [TestCase(20000000000, "353130", 1, true, 0)] + [TestCase(20000000030, "353130", 0, false, 0)] + [TestCase(20000000030, "353130", 1, true, -1)] + [TestCase(20000000060, "353130", 1, false, 0)] + [TestCase(20000000060, "353130", 2, true, -2)] + public void ValidateOffsetTest(long timestamp, string code, int toleranceWindow, bool expectedResult, int expectedResync) + { + using OtpSecret secret = OtpSecret.Parse(secret1); + Totp totp = new(secret, 30, code.Length); + DateTimeOffset time = DateTimeOffset.FromUnixTimeSeconds(timestamp); + + bool result = totp.Validate((OtpCode)code, toleranceWindow, time, out int resyncValue); + + Assert.That(result, Is.EqualTo(expectedResult)); + Assert.That(resyncValue, Is.EqualTo(expectedResync)); + } +} diff --git a/SimpleOTP.sln b/SimpleOTP.sln index 889ed15..ab7225d 100644 --- a/SimpleOTP.sln +++ b/SimpleOTP.sln @@ -1,51 +1,40 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.6.30114.105 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleOTP", "SimpleOTP\SimpleOTP.csproj", "{518EF6D5-DB32-4406-B289-6E11DB6E1D67}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleOTP.Test", "SimpleOTP.Test\SimpleOTP.Test.csproj", "{54AC322C-6119-456B-BFE6-CCF3CC504C56}" -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 - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|Any CPU.Build.0 = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|x64.ActiveCfg = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|x64.Build.0 = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|x86.ActiveCfg = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Debug|x86.Build.0 = Debug|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|Any CPU.ActiveCfg = Release|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|Any CPU.Build.0 = Release|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|x64.ActiveCfg = Release|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|x64.Build.0 = Release|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|x86.ActiveCfg = Release|Any CPU - {518EF6D5-DB32-4406-B289-6E11DB6E1D67}.Release|x86.Build.0 = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|Any CPU.Build.0 = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|x64.ActiveCfg = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|x64.Build.0 = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|x86.ActiveCfg = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Debug|x86.Build.0 = Debug|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|Any CPU.ActiveCfg = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|Any CPU.Build.0 = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|x64.ActiveCfg = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|x64.Build.0 = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|x86.ActiveCfg = Release|Any CPU - {54AC322C-6119-456B-BFE6-CCF3CC504C56}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {247D25FB-623A-4CF6-ABD4-06B4CBF0AD3E} - EndGlobalSection -EndGlobal + +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}") = "libraries", "libraries", "{3C07FF44-31AD-4E2B-A651-89890708590A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleOTP.DependencyInjection", "libraries\SimpleOTP.DependencyInjection\SimpleOTP.DependencyInjection.csproj", "{014512A7-1119-4410-8B93-4B6B0B30CE44}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleOTP", "libraries\SimpleOTP\SimpleOTP.csproj", "{3D52F0F0-601B-4DB3-B82F-46AFF352C046}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleOTP.Tests", "SimpleOTP.Tests\SimpleOTP.Tests.csproj", "{ABD83245-A354-45E0-88B5-DD38863C5DEA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {014512A7-1119-4410-8B93-4B6B0B30CE44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {014512A7-1119-4410-8B93-4B6B0B30CE44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {014512A7-1119-4410-8B93-4B6B0B30CE44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {014512A7-1119-4410-8B93-4B6B0B30CE44}.Release|Any CPU.Build.0 = Release|Any CPU + {3D52F0F0-601B-4DB3-B82F-46AFF352C046}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D52F0F0-601B-4DB3-B82F-46AFF352C046}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D52F0F0-601B-4DB3-B82F-46AFF352C046}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D52F0F0-601B-4DB3-B82F-46AFF352C046}.Release|Any CPU.Build.0 = Release|Any CPU + {ABD83245-A354-45E0-88B5-DD38863C5DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABD83245-A354-45E0-88B5-DD38863C5DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABD83245-A354-45E0-88B5-DD38863C5DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABD83245-A354-45E0-88B5-DD38863C5DEA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {014512A7-1119-4410-8B93-4B6B0B30CE44} = {3C07FF44-31AD-4E2B-A651-89890708590A} + {3D52F0F0-601B-4DB3-B82F-46AFF352C046} = {3C07FF44-31AD-4E2B-A651-89890708590A} + EndGlobalSection +EndGlobal diff --git a/SimpleOTP/Enums/Algorithm.cs b/SimpleOTP/Enums/Algorithm.cs deleted file mode 100644 index aed2871..0000000 --- a/SimpleOTP/Enums/Algorithm.cs +++ /dev/null @@ -1,33 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -namespace SimpleOTP.Enums -{ - /// - /// Available OTP encryption algorithms. - /// - public enum Algorithm - { - /// - /// HMAC-SHA1 hasing algorithm (default)
- /// RFC 3174 - ///
- SHA1 = 0, - - /// - /// HMAC-SHA256 hasing algorithm
- /// RFC 4634 - ///
- SHA256 = 1, - - /// - /// HMAC-SHA512 hasing algorithm
- /// RFC 4634 - ///
- SHA512 = 2 - } -} \ No newline at end of file diff --git a/SimpleOTP/Enums/OTPType.cs b/SimpleOTP/Enums/OTPType.cs deleted file mode 100644 index d2b7203..0000000 --- a/SimpleOTP/Enums/OTPType.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -namespace SimpleOTP.Enums -{ - /// - /// OTP algorithm types. - /// - public enum OTPType - { - /// - /// Time-based One-Time Password
- /// RFC 6238 - ///
- TOTP = 0, - - /// - /// HMAC-based One-Time Password
- /// /// RFC 4226 - ///
- HOTP = 1 - } -} \ No newline at end of file diff --git a/SimpleOTP/GlobalSuppressions.cs b/SimpleOTP/GlobalSuppressions.cs deleted file mode 100644 index 0ee7a9a..0000000 --- a/SimpleOTP/GlobalSuppressions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -/* - * This file is used by Code Analysis to maintain SuppressMessage - * attributes that are applied to this project. - * Project-level suppressions either have no target or are given - * a specific target and scoped to a namespace, type, member, etc. -*/ - -using System.Diagnostics.CodeAnalysis; - -[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:Braces should not be omitted", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1625:Element documentation should not be copied and pasted", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1519:Braces should not be omitted from multi-line child statement", Justification = "Reviewd by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "Reviewd by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1413:Use trailing comma in multi-line initializers", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] -[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Reviewed by E. Fox", Scope = "namespaceanddescendants", Target = "~N:SimpleOTP")] \ No newline at end of file diff --git a/SimpleOTP/Helpers/Base32Encoder.cs b/SimpleOTP/Helpers/Base32Encoder.cs deleted file mode 100644 index 9aacc65..0000000 --- a/SimpleOTP/Helpers/Base32Encoder.cs +++ /dev/null @@ -1,63 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Linq; - -namespace SimpleOTP.Helpers -{ - /// - /// Helper class which contains methods for encoding and decoding Base32 bytes. - /// - internal static class Base32Encoder - { - // Standard RFC 4648 Base32 alphabet - private const string AllowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - - /// - /// Encode byte array to Base32 string. - /// - /// Byte array to encode. - /// Base32 string. - internal static string Encode(byte[] data) - { - string binary = string.Empty; - foreach (byte b in data) - binary += Convert.ToString(b, 2).PadLeft(8, '0'); // Getting binary sequence to split into 5 digits - - int numberOfBlocks = (binary.Length / 5) + Math.Clamp(binary.Length % 5, 0, 1); - string[] sequence = Enumerable.Range(0, numberOfBlocks) - .Select(i => binary.Substring(i * 5, Math.Min(5, binary.Length - (i * 5))).PadRight(5, '0')) - .ToArray(); // Splitting sequence on groups of 5 - - string output = string.Empty; - foreach (string str in sequence) - output += AllowedCharacters[Convert.ToInt32(str, 2)]; - - output = output.PadRight(output.Length + (output.Length % 8), '='); - - return output; - } - - /// - /// Decode Base32 string into byte array. - /// - /// Base32-encoded string. - /// Initial byte array. - internal static byte[] Decode(string base32str) - { - base32str = base32str.Replace("=", string.Empty); // Removing padding - - string[] quintets = base32str.Select(i => Convert.ToString(AllowedCharacters.IndexOf(i), 2).PadLeft(5, '0')).ToArray(); // Getting quintets - string binary = string.Join(null, quintets); - - byte[] output = Enumerable.Range(0, binary.Length / 8).Select(i => Convert.ToByte(binary.Substring(i * 8, 8), 2)).ToArray(); - - return output; - } - } -} \ No newline at end of file diff --git a/SimpleOTP/Helpers/SecretGenerator.cs b/SimpleOTP/Helpers/SecretGenerator.cs deleted file mode 100644 index 94a6501..0000000 --- a/SimpleOTP/Helpers/SecretGenerator.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -namespace SimpleOTP.Helpers -{ - /// - /// Helper class for OTP secret generation. - /// - public static class SecretGenerator - { - /// - /// Generate OTP secret key. - /// - /// Length of the key in bits
- /// It should belong to [128-160] bit span
- /// Default is: 160 bits. - /// Number of bits will be rounded down to the nearest number which divides by 8. - /// Base32 encoded alphanumeric string with length form 16 to 20 characters. - public static string GenerateSecret(int length = 160) - { - if (length > 160 || length < 128) - throw new ArgumentOutOfRangeException(nameof(length), "Invalid key length. It should belong to [128-160] bits span"); - - byte[] key = new byte[length / 8]; - new Random().NextBytes(key); - - return Base32Encoder.Encode(key); - } - } -} \ No newline at end of file diff --git a/SimpleOTP/Helpers/UriParser.cs b/SimpleOTP/Helpers/UriParser.cs deleted file mode 100644 index af60825..0000000 --- a/SimpleOTP/Helpers/UriParser.cs +++ /dev/null @@ -1,72 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Collections.Specialized; -using System.Linq; -using System.Web; - -using SimpleOTP.Enums; -using SimpleOTP.Models; - -namespace SimpleOTP.Helpers -{ - /// - /// Helper class which contains methods to parse OTP auth URIs. - /// - internal static class UriParser - { - /// - /// Parses OTP Auth URI and returns configuration object for further processing. - /// URI should be correctly formed. - /// - /// - /// For more information please refer to Key Uri Format. - /// - /// OTP Auth URI. Should be correctly formed. - /// For more information please refer to Key Uri Format. - /// configuration object, which contains data for OTP generation. - internal static OTPConfiguration ParseUri(Uri uri) - { - if (uri.Scheme != "otpauth") - throw new ArgumentException("Malformed link: Invalid scheme"); - if (!new[] { "hotp", "totp" }.Contains(uri.Host)) - throw new ArgumentException("Malformed link: Invalid OTP type"); - if (string.IsNullOrWhiteSpace(uri.LocalPath[1..])) - throw new ArgumentException("Malformed link: Invalid label"); - - NameValueCollection query = HttpUtility.ParseQueryString(uri.Query); - if (!query.AllKeys.Contains("secret")) - throw new ArgumentException("Malformed link: No secret provided"); - if (uri.Host == "hotp" && !query.AllKeys.Contains("counter")) - throw new ArgumentException("Malformed link: No counter provided for HOTP"); - if (!uri.LocalPath[1..].Contains(':') && !query.AllKeys.Contains("issuer")) - throw new ArgumentException("Malformed link: No issuer provided"); - - string[] label = uri.LocalPath[1..].Split(':'); - OTPConfiguration item = new () - { - Type = uri.Host == "totp" ? OTPType.TOTP : OTPType.HOTP, - IssuerLabel = label.Length > 1 ? label[0] : query["issuer"], - AccountName = label.Length > 1 ? label[1] : uri.LocalPath[1..], - Secret = query["secret"], - Issuer = query["issuer"] ?? label[0], - Algorithm = query["algorithm"]?.ToUpperInvariant() switch - { - "SHA256" => Algorithm.SHA256, - "SHA512" => Algorithm.SHA512, - _ => Algorithm.SHA1 - }, - Digits = int.Parse(query["digits"] ?? "6"), - Counter = int.Parse(query["counter"] ?? "0"), - Period = TimeSpan.FromSeconds(int.Parse(query["period"] ?? "30")) - }; - - return item; - } - } -} \ No newline at end of file diff --git a/SimpleOTP/Models/OTPCode.cs b/SimpleOTP/Models/OTPCode.cs deleted file mode 100644 index 12e634a..0000000 --- a/SimpleOTP/Models/OTPCode.cs +++ /dev/null @@ -1,51 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; - -namespace SimpleOTP.Models -{ - /// - /// OTP code object model. - /// - public record OTPCode - { - /// - /// Gets or sets OTP code. - /// - public int Code { get; set; } - - /// - /// Gets or sets date-time until the code is valid. - /// - public DateTime? Expiring { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public OTPCode() - { - } - - /// - /// Initializes a new instance of the class.
- /// Use this constructor only for HOTP key. Otherwise, fill out all properties. - ///
- /// OTP code. - public OTPCode(int code) => - Code = code; - - /// - /// Gets valid 6 digit or more OTP code. - /// - /// String formatter. Other variation: - /// "000 000" - /// Formatted OTP code string with 6 or more digits. - public string GetCode(string formatter = "000000") => - Code.ToString(formatter); - } -} \ No newline at end of file diff --git a/SimpleOTP/Models/OTPConfiguration.cs b/SimpleOTP/Models/OTPConfiguration.cs deleted file mode 100644 index 3d6ebae..0000000 --- a/SimpleOTP/Models/OTPConfiguration.cs +++ /dev/null @@ -1,228 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Net.Http; -using System.Threading.Tasks; -using System.Web; - -using SimpleOTP.Enums; -using SimpleOTP.Helpers; - -namespace SimpleOTP.Models -{ - /// - /// OTP generator configuration object. - /// - public record OTPConfiguration - { - /// - /// Gets or sets unique identifier of current configuration instance. - /// - public Guid Id { get; set; } = Guid.NewGuid(); - - /// - /// Gets or sets OTP algorithm type. - /// - public OTPType Type { get; set; } = OTPType.TOTP; - - /// - /// Gets or sets name of config issuer/service. - /// - public string IssuerLabel { get; set; } - - /// - /// Gets or sets username or email of current config. - /// - public string AccountName { get; set; } - - /// - /// Gets or sets secret key for OTP code generation. - /// - public string Secret { get; set; } - - /// - /// Gets or sets internal issuer name for additional identification. Currently should be the same with . - /// - public string Issuer { get; set; } - - /// - /// Gets or sets oTP hashing algorithm. - /// - public Algorithm Algorithm { get; set; } = Algorithm.SHA1; - - /// - /// Gets or sets number of digits of OTP code. - /// - public int Digits { get; set; } = 6; - - /// - /// Gets or sets counter for HOTP generation. Update each time password has been generated.
- /// HOTP only. - ///
- public long Counter { get; set; } = 0; - - /// - /// Gets or sets time of OTP validity interval. Used to calculate TOTP counter. - /// TOTP only. - /// - public TimeSpan Period { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Generate a new OTP configuration to send it to client. - /// - /// - /// - /// Algorithm parameters: - /// - /// OTP algorithm - /// Time-based OTP - /// - /// - /// Key length - /// 160 bit (20 characters) - /// - /// - /// Hashing algorithm - /// HMAC-SHA-1 - /// - /// - /// Number of digits - /// 6 - /// - /// - /// Period - /// 30 seconds - /// - /// - /// - /// Name of your application/service. - /// Username/email of the user. - /// Valid configuraion. - public static OTPConfiguration GenerateConfiguration(string issuer, string accountName) => - new () - { - Issuer = issuer, - IssuerLabel = issuer, - AccountName = accountName, - Secret = SecretGenerator.GenerateSecret() - }; - - /// - /// Load OTP configuraiton with default parameters. - /// - /// - /// - /// Algorithm parameters: - /// - /// OTP algorithm - /// Time-based OTP - /// - /// - /// Hashing algorithm - /// HMAC-SHA-1 - /// - /// - /// Number of digits - /// 6 - /// - /// - /// Period - /// 30 seconds - /// - /// - /// - /// OTP generator secret key (Base32 encoded string). - /// Name of your application/service. - /// Username/email of the user. - /// Valid configuraion. - public static OTPConfiguration GetConfiguration(string secret, string issuer, string accountName) => - new () - { - Issuer = issuer, - IssuerLabel = issuer, - AccountName = accountName, - Secret = secret - }; - - /// - /// Loads OTP configuration from OTP AUTH URI. - /// - /// - /// For more information please refer to Key Uri Format. - /// - /// OTP Auth URI. Should be correctly formed. - /// Valid configuraion. - public static OTPConfiguration GetConfiguration(Uri uri) => - Helpers.UriParser.ParseUri(uri); - - /// - /// Loads OTP configuration from OTP AUTH URI. - /// - /// - /// For more information please refer to Key Uri Format. - /// - /// OTP Auth URI. Should be correctly formed. - /// Valid configuraion. - public static OTPConfiguration GetConfiguration(string uri) => - GetConfiguration(new Uri(uri)); - - /// - /// Gets URI from current configuration to reuse it somewhere else. - /// - /// Valid OTP AUTH URI. - public Uri GetUri() - { - string path = $"otpauth://{Type}/{IssuerLabel}"; - if (!string.IsNullOrWhiteSpace(AccountName)) - path += $":{AccountName}"; - path += $"?secret={Secret}&issuer={Issuer}"; - if (Algorithm != Algorithm.SHA1) - path += $"&algorithm={Algorithm}"; - if (Digits != 6) - path += $"&digits={Digits}"; - if (Type == OTPType.HOTP) - path += $"&counter={Counter}"; - if (Type == OTPType.TOTP && Period.TotalSeconds != 30) - path += $"&period={(int)Period.TotalSeconds}"; - - return new Uri(path); - } - - /// - /// Returns secret key separated with whitespaces on groups of 4. - /// - /// Formatted secret key string. - public string GetFancySecret() - { - string secret = Secret; - for (int k = 0; 4 + (5 * k) < secret.Length; k++) - secret = secret.Insert(4 + (5 * k), " "); - return secret; - } - - /// - /// Generates QR code image for current configuration with Google Chart API. - /// - /// QR code image size in pixels. - /// Web request timeout in seconds. - /// string-encoded PNG image. - public async Task GetQrImage(int qrCodeSize = 300, int requestTimeout = 30) - { - HttpClient client = new () { Timeout = TimeSpan.FromSeconds(requestTimeout) }; - HttpResponseMessage response = client.GetAsync($"https://chart.googleapis.com/chart?cht=qr&chs={qrCodeSize}x{qrCodeSize}&chl={HttpUtility.UrlEncode(GetUri().AbsoluteUri)}").Result; - - if (!response.IsSuccessStatusCode) - throw new HttpRequestException($"Response status code indicates that request has failed (Response code: {response.StatusCode})"); - - byte[] imageBytes = await response.Content.ReadAsByteArrayAsync(); - string imageString = @$"data:image/png;base64,{Convert.ToBase64String(imageBytes)}"; - - return imageString; - } - } -} \ No newline at end of file diff --git a/SimpleOTP/OTPFactory.cs b/SimpleOTP/OTPFactory.cs deleted file mode 100644 index 600ae68..0000000 --- a/SimpleOTP/OTPFactory.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.ComponentModel; -using System.Timers; - -using SimpleOTP.Models; - -namespace SimpleOTP -{ - /// - /// Represents method that will be called when new OTP code is generated. - /// - /// New OTP code instance. - public delegate void OTPCodeUpdatedEventHandler(OTPCode code); - - /// - /// Class used to streamline OTP code generation on client devices. - /// - /// - /// - /// var factory = new (config);
- /// factory.CodeUpdated += (newCode) => Console.WriteLine(newCode.Code); - ///
- ///
- public class OTPFactory : INotifyPropertyChanged, IDisposable - { - private readonly Timer _timer = new (1000); - - private OTPCode _currentCode; - - /// - /// Gets or sets current valid OTP code instance. - /// - public OTPCode CurrentCode - { - get => _currentCode; - set - { - if (_currentCode == value) - return; - _currentCode = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentCode))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TimeLeft))); - CodeUpdated?.Invoke(value); - } - } - - private OTPConfiguration _configuration; - - /// - /// Gets or sets OTP configuration of the current instance. - /// - public OTPConfiguration Configuration - { - get => _configuration; - set - { - if (_configuration == value) - return; - _configuration = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Configuration))); - } - } - - /// - /// Gets time left before current OTP code expires. - /// - public TimeSpan? TimeLeft => CurrentCode?.Expiring - DateTime.UtcNow; - - /// - /// Event is fired when new OTP code is generated. - /// - public event OTPCodeUpdatedEventHandler CodeUpdated; - - /// - public event PropertyChangedEventHandler PropertyChanged; - - /// - /// Initializes a new instance of the class. - /// - /// OTP configuration for codes producing. - /// Interval for timer updates in milliseconds. - public OTPFactory(OTPConfiguration configuration, int timerUpdateInterval = 1000) - { - Configuration = configuration; - CurrentCode = OTPService.GenerateCode(ref configuration); - - _timer.Interval = timerUpdateInterval; - _timer.Elapsed += TimerElapsed; - _timer.Start(); - } - - /// - public void Dispose() - { - _timer.Stop(); - _timer.Elapsed -= TimerElapsed; - _timer.Dispose(); - GC.SuppressFinalize(this); - } - - private void TimerElapsed(object sender, ElapsedEventArgs args) - { - if (TimeLeft.Value.TotalSeconds <= 0) - CurrentCode = OTPService.GenerateCode(ref _configuration); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TimeLeft))); - } - } -} \ No newline at end of file diff --git a/SimpleOTP/OTPService.cs b/SimpleOTP/OTPService.cs deleted file mode 100644 index fbc1154..0000000 --- a/SimpleOTP/OTPService.cs +++ /dev/null @@ -1,154 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; - -using SimpleOTP.Enums; -using SimpleOTP.Helpers; -using SimpleOTP.Models; - -namespace SimpleOTP -{ - /// - /// Service class for generating and validating OTP codes. - /// - public static class OTPService - { - /// - /// Generates a new OTP code with provided configuration. - /// - /// - /// If you're using HOTP algorithm, save after calling the function. - /// - /// OTP configuration object. - /// instance with generated code.
- /// If OTP algorithm is HOTP, counter is increased by 1. - ///
- public static OTPCode GenerateCode(ref OTPConfiguration target) => - GenerateCode(ref target, DateTime.UtcNow); - - /// - /// Generates a new TOTP code with provided configuration and for specific interval. - /// - /// - /// If you're using HOTP algorithm, save after calling the function.
- /// If you're using HOTP algorithm, will be ignored. - ///
- /// OTP configuration object. - /// for which the OTP should be generated. - /// instance with generated code.
- /// If OTP algorithm is HOTP, counter is increased by 1. - ///
- public static OTPCode GenerateCode(ref OTPConfiguration target, DateTime date) - { - byte[] keyBytes = Base32Encoder.Decode(target.Secret.ToUpperInvariant().Replace(" ", string.Empty)); - long counter = target.Type == OTPType.HOTP ? target.Counter : GetCurrentCounter(date.ToUniversalTime(), (int)target.Period.TotalSeconds); - byte[] counterBytes = BitConverter.GetBytes(counter); - - // IDK da fuk is this and at this point I'm too afraid to ask - if (BitConverter.IsLittleEndian) - Array.Reverse(counterBytes); - - HMAC hmac = target.Algorithm switch - { - Algorithm.SHA256 => new HMACSHA256(keyBytes), - Algorithm.SHA512 => new HMACSHA512(keyBytes), - _ => new HMACSHA1(keyBytes) - }; - - byte[] hash = hmac.ComputeHash(counterBytes); - - // Not mine code, so fuck off - int offset = hash[^1] & 0xf; - - // Convert the 4 bytes into an integer, ignoring the sign. - int binary = - ((hash[offset] & 0x7f) << 24) - | (hash[offset + 1] << 16) - | (hash[offset + 2] << 8) - | hash[offset + 3]; - - OTPCode code = new (binary % (int)Math.Pow(10, target.Digits)); - - if (target.Type == OTPType.HOTP) - target.Counter++; // Incrementing counter for HMAC OTP type - else - code.Expiring = DateTime.UnixEpoch.AddSeconds((counter + 1) * target.Period.TotalSeconds); - - return code; - } - - /// - /// Validates provided HOTP code with provided parameters. - /// - /// - /// Use this method only with HOTP codes. - /// - /// HOTP code to validate. - /// OTP configuration for check codes generation. - /// Counter span from which OTP codes remain valid. - /// Defines whether method should resync of the or not after successful validation. - /// True if code is valid, False if it isn't. - public static bool ValidateHotp(int otp, ref OTPConfiguration target, int toleranceSpan, bool resyncCounter) - { - if (target?.Type != OTPType.HOTP) - throw new ArgumentException("Invalid configuration. This method only validates HOTP codes. For TOTP codes use OTPService.ValidateTotp()"); - long currentCounter = target.Counter; - List<(int Code, long Counter)> codes = new (); - for (long i = currentCounter - toleranceSpan; i <= currentCounter + toleranceSpan; i++) - { - OTPConfiguration testTarget = target with { Counter = i }; - codes.Add((GenerateCode(ref testTarget).Code, testTarget.Counter - 1)); - } - - bool isValid = codes.Any(i => i.Code == otp); - if (isValid && resyncCounter) - target.Counter = codes.Find(i => i.Code == otp).Counter; - return isValid; - } - - /// - /// Validates provided TOTP code with provided parameters. - /// - /// - /// Use this method only with Time-based OTP codes. - /// - /// OTP code to validate. - /// OTP configuration for check codes generation. - /// Time span from which OTP codes remain valid.
- /// Default: 15 seconds. - /// True if code is valid, False if it isn't. - public static bool ValidateTotp(int otp, OTPConfiguration target, TimeSpan? toleranceTime = null) - { - if (target?.Type != OTPType.TOTP) - throw new ArgumentException("Invalid configuration. This method only validates TOTP codes. For HOTP codes use OTPService.ValidateHotp()"); - toleranceTime ??= TimeSpan.FromSeconds(15); - DateTime now = DateTime.UtcNow; - List codes = new (); - for (DateTime time = now - toleranceTime.Value; time <= now + toleranceTime; time += target.Period) - codes.Add(GenerateCode(ref target, time).Code); - - return codes.Any(i => i == otp); - } - - /// - [Obsolete("Use ValidateHotp() instead.")] - public static bool ValidateCode(int otp, ref OTPConfiguration target, int toleranceSpan, bool resyncCounter) => - ValidateHotp(otp, ref target, toleranceSpan, resyncCounter); - - /// - [Obsolete("Use ValidateTotp() instead.")] - public static bool ValidateCode(int otp, OTPConfiguration target, TimeSpan? toleranceTime = null) => - ValidateTotp(otp, target, toleranceTime); - - private static long GetCurrentCounter(DateTime date, int period) => - (long)(date - DateTime.UnixEpoch).TotalSeconds / period; - } -} \ No newline at end of file diff --git a/SimpleOTP/Properties/AssemblyInfo.cs b/SimpleOTP/Properties/AssemblyInfo.cs deleted file mode 100644 index edb8cf9..0000000 --- a/SimpleOTP/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ------------------------------------------------------------ -// Copyright ©2021 Eugene Fox. All rights reserved. -// Code by Eugene Fox (aka XFox) -// -// Licensed under MIT license (https://opensource.org/licenses/MIT) -// ------------------------------------------------------------ - -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// Setting ComVisible to false makes the types in this assembly not visible to COM -// components. If you need to access a type in this assembly from COM, set the ComVisible -// attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM. -[assembly: Guid("776914d2-d408-4755-894f-a230d372f07d")] -[assembly: InternalsVisibleTo("SimpleOTP.Tests")] \ No newline at end of file diff --git a/SimpleOTP/SimpleOTP.csproj b/SimpleOTP/SimpleOTP.csproj deleted file mode 100644 index 7c3b95f..0000000 --- a/SimpleOTP/SimpleOTP.csproj +++ /dev/null @@ -1,55 +0,0 @@ - - - - net5.0;netstandard2.1;netcoreapp3.1 - 9 - true - true - true - true - - - - SimpleOTP - SimpleOTP - 1.2.3 - .NET library for TOTP/HOTP implementation on server (ASP.NET) or client (Xamarin) side - Eugene Fox - FoxDev Studio - LICENSE - ©2021 Eugene Fox - Git - https://github.com/XFox111/SimpleOTP - en-US - otp;totp;dotnet;hotp;authenticator;2fa;mfa;security;oath - - Fixed invalid code generation with secrets which are lowercase or space-separated - - - - 1701;1702;AD0001 - - - - 1701;1702;AD0001 - - - - - - True - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - \ No newline at end of file diff --git a/SimpleOTP/stylecop.json b/SimpleOTP/stylecop.json deleted file mode 100644 index 3c594d2..0000000 --- a/SimpleOTP/stylecop.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": - { - "documentationRules": - { - "companyName": "Eugene Fox", - "copyrightText": "------------------------------------------------------------\nCopyright ©{year} {companyName}. All rights reserved.\nCode by {author}\n\nLicensed under MIT license (https://opensource.org/licenses/MIT)\n------------------------------------------------------------", - "xmlHeader": false, - "variables": - { - "year": "2021", - "author": "Eugene Fox (aka XFox)" - }, - "headerDecoration": "------------------------------------------------------------", - "fileNamingConvention": "stylecop", - "documentationCulture": "en-US" - }, - "indentation": - { - "useTabs": true - }, - "layoutRules": - { - "newlineAtEndOfFile": "omit" - }, - "maintainabilityRules": - { - "topLevelTypes": [ "class", "interface", "struct" ] - }, - "namingRules": - { - "allowCommonHungarianPrefixes": false, - "allowedNamespaceComponents": [], - "tupleElementNameCasing": "PascalCase" - }, - "orderingRules": - { - "blankLinesBetweenUsingGroups": "require", - "systemUsingDirectivesFirst": true, - "usingDirectivesPlacement": "outsideNamespace" - }, - "readabilityRules": - { - "allowBuiltInTypeAliases": false - } - } -} \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..7af2e47 Binary files /dev/null and b/assets/icon.png differ diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index ddd9a36..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,70 +0,0 @@ -trigger: -- master - -pool: - vmImage: 'windows-latest' - -variables: - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - -steps: -- task: NuGetToolInstaller@1 - displayName: 'Install NuGet tools' - -- task: NuGetCommand@2 - displayName: 'Restore NuGet packages' - inputs: - command: 'restore' - restoreSolution: '**/*.sln' - feedsToUse: 'select' - -- task: VSBuild@1 - displayName: 'Build library' - inputs: - solution: '**/SimpleOTP.csproj' - msbuildArgs: '/p:NoWarn=AD0001' - platform: '$(buildPlatform)' - configuration: '$(buildConfiguration)' - msbuildArchitecture: 'x64' - -- task: VSBuild@1 - displayName: 'Build tests' - inputs: - solution: '**\*.Test.csproj' - -- task: VSTest@2 - displayName: 'Run tests' - inputs: - testSelector: 'testAssemblies' - testAssemblyVer2: | - **\*.Tests.dll - !**\*TestAdapter.dll - !**\obj\** - !**\bin\**\ref\** - searchFolder: '$(System.DefaultWorkingDirectory)' - codeCoverageEnabled: true - -- task: PowerShell@2 - displayName: 'Copy changelog' - inputs: - targetType: 'inline' - script: | - New-Item $(Build.ArtifactStagingDirectory)\Changelog.md - (Select-Xml -Path SimpleOTP.csproj -XPath /Project/PropertyGroup/PackageReleaseNotes | Select-Object -ExpandProperty Node).InnerText | Set-Content $(Build.ArtifactStagingDirectory)\changelog.md -Encoding UTF8 - workingDirectory: '$(Build.SourcesDirectory)\SimpleOTP' - -- task: CopyFiles@2 - displayName: 'Copy package to staging' - inputs: - SourceFolder: '$(System.DefaultWorkingDirectory)' - Contents: '**/Release/**/*.nupkg' - TargetFolder: '$(Build.ArtifactStagingDirectory)' - flattenFolders: true - -- task: PublishBuildArtifacts@1 - displayName: 'Drop artifacts' - inputs: - PathtoPublish: '$(Build.ArtifactStagingDirectory)' - ArtifactName: 'Artifacts' - publishLocation: 'Container' \ No newline at end of file diff --git a/libraries/SimpleOTP.DependencyInjection/AssemblyInfo.cs b/libraries/SimpleOTP.DependencyInjection/AssemblyInfo.cs new file mode 100644 index 0000000..5113760 --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SimpleOTP.Tests")] diff --git a/libraries/SimpleOTP.DependencyInjection/IOtpService.cs b/libraries/SimpleOTP.DependencyInjection/IOtpService.cs new file mode 100644 index 0000000..fee4763 --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/IOtpService.cs @@ -0,0 +1,33 @@ +namespace SimpleOTP.DependencyInjection; + +/// +/// Provides methods for generating and validating One-Time Passwords. +/// +public interface IOtpService +{ + /// + /// Creates an OTP URI for specified user and secret. + /// + /// The username of the user. + /// The secret to use. + /// (only for HOTP) The counter to use. + /// The generated URI. + public Uri CreateUri(string username, OtpSecret secret, long counter = 0); + + /// + /// Creates an OTP code for specified user and secret. + /// + /// The secret to use. + /// (only for HOTP) The counter to use. + public OtpCode GenerateCode(OtpSecret secret, long counter = 0); + + /// + /// Validates an OTP code for specified user and secret. + /// + /// The code to validate. + /// The secret to use. + /// The resync value. Shows how much the code is ahead or behind the current counter value. + /// (only for HOTP) The counter to use. + /// true if the code is valid; otherwise, false. + public bool ValidateCode(OtpCode code, OtpSecret secret, out int resyncValue, long counter = 0); +} diff --git a/libraries/SimpleOTP.DependencyInjection/OtpOptions.cs b/libraries/SimpleOTP.DependencyInjection/OtpOptions.cs new file mode 100644 index 0000000..56f2e5f --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/OtpOptions.cs @@ -0,0 +1,57 @@ +using System.Collections.Specialized; + +namespace SimpleOTP.DependencyInjection; + +/// +/// Provides options for the One-Time Password service. +/// +public class OtpOptions +{ + /// + /// The name of the issuer. + /// + public required string Issuer { get; set; } + + /// + /// The issuer domain. + /// + /// + /// IMPORTANT: Using this property will imply adherence to the Apple specification. + /// + public string? IssuerDomain { get; set; } + + /// + /// The algorithm to use. + /// + public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1; + + /// + /// The number of digits in the OTP code. + /// + public int Digits { get; set; } = 6; + + /// + /// The number of seconds between each OTP code. + /// + public int Period { get; set; } = 30; + + /// + /// The type of One-Time Password to generate. + /// + public OtpType Type { get; set; } = OtpType.Totp; + + /// + /// The format of OTP URIs. + /// + public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google | OtpUriFormat.Minimal; + + /// + /// The tolerance span for the OTP codes validation. + /// + public ToleranceSpan ToleranceSpan { get; set; } = ToleranceSpan.Default; + + /// + /// Custom properties to place in OTP URIs. + /// + public NameValueCollection CustomProperties { get; } = []; +} diff --git a/libraries/SimpleOTP.DependencyInjection/OtpService.cs b/libraries/SimpleOTP.DependencyInjection/OtpService.cs new file mode 100644 index 0000000..9aacb55 --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/OtpService.cs @@ -0,0 +1,95 @@ +using System.Collections.Specialized; +using SimpleOTP.Fluent; + +namespace SimpleOTP.DependencyInjection; + +/// +/// Provides methods for generating and validating One-Time Passwords. +/// +/// The configuration for the One-Time Password service. +internal class OtpService(OtpOptions configuration) : IOtpService +{ + private readonly string _issuerName = configuration.Issuer; + private readonly string? _issuerDomain = configuration.IssuerDomain; + private readonly OtpAlgorithm _algorithm = configuration.Algorithm; + private readonly OtpType _type = configuration.Type; + private readonly OtpUriFormat _format = configuration.UriFormat | + (string.IsNullOrWhiteSpace(configuration.IssuerDomain) ? 0 : OtpUriFormat.Apple); + private readonly int _digits = configuration.Digits; + private readonly int _period = configuration.Period; + private readonly NameValueCollection _customProperties = configuration.CustomProperties; + private readonly ToleranceSpan _tolerance = configuration.ToleranceSpan; + + /// + /// Creates an OTP URI for specified user and secret. + /// + /// The username of the user. + /// The secret to use. + /// (only for HOTP) The counter to use. + /// The generated URI. + public Uri CreateUri(string username, OtpSecret secret, long counter = 0) + { + OtpConfig config = new(username) + { + Algorithm = _algorithm, + Type = _type, + Issuer = _issuerName, + Digits = _digits, + Period = _period, + + Secret = secret, + Counter = counter + }; + + if (!string.IsNullOrWhiteSpace(_issuerDomain)) + config.WithAppleIssuer(_issuerName, _issuerDomain); + + config.CustomProperties.Add(_customProperties); + + return config.ToUri(_format); + } + + /// + /// Creates an OTP code for specified user and secret. + /// + /// The secret to use. + /// (only for HOTP) The counter to use. + /// The generated code. + /// The service was not configured properly. Check the "Authenticator:Type" configuration. + public OtpCode GenerateCode(OtpSecret secret, long counter = 0) + { + using OtpSecret secretClone = OtpSecret.CreateCopy(secret); + + Otp generator = _type switch + { + OtpType.Hotp => new Hotp(secret, counter, _algorithm, _digits), + OtpType.Totp => new Totp(secret, _period, _algorithm, _digits), + _ => throw new NotSupportedException("The service was not configured properly. Check the \"Authenticator:Type\" configuration.") + }; + + return generator.Generate(); + } + + /// + /// Validates an OTP code for specified user and secret. + /// + /// The code to validate. + /// The secret to use. + /// The resync value. Shows how much the code is ahead or behind the current counter value. + /// (only for HOTP) The counter to use. + /// true if the code is valid; otherwise, false. + /// The service was not configured properly. Check the "Authenticator:Type" configuration. + public bool ValidateCode(OtpCode code, OtpSecret secret, out int resyncValue, long counter = 0) + { + using OtpSecret secretClone = OtpSecret.CreateCopy(secret); + + Otp generator = _type switch + { + OtpType.Hotp => new Hotp(secret, counter, _algorithm, _digits), + OtpType.Totp => new Totp(secret, _period, _algorithm, _digits), + _ => throw new NotSupportedException("The service was not configured properly. Check the \"Authenticator:Type\" configuration.") + }; + + return generator.Validate(code, _tolerance, out resyncValue); + } +} diff --git a/libraries/SimpleOTP.DependencyInjection/OtpServiceConfig.cs b/libraries/SimpleOTP.DependencyInjection/OtpServiceConfig.cs new file mode 100644 index 0000000..d5afe4e --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/OtpServiceConfig.cs @@ -0,0 +1,76 @@ +namespace SimpleOTP.DependencyInjection; + +/// +/// Configuration for the One-Time Password service. +/// +public class OtpServiceConfig +{ + /// + /// The name of the issuer. + /// + public string Issuer { get; set; } = null!; + + /// + /// The issuer domain. + /// + /// + /// IMPORTANT: Using this property will imply adherence to the Apple specification. + /// + public string? IssuerDomain { get; set; } + + /// + /// The algorithm to use. + /// + public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1; + + /// + /// The number of digits in the OTP code. + /// + public int Digits { get; set; } = 6; + + /// + /// The number of seconds between each OTP code. + /// + public int Period { get; set; } = 30; + + /// + /// The type of One-Time Password to generate. + /// + public OtpType Type { get; set; } = OtpType.Totp; + + /// + /// The format of OTP URIs. + /// + public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google; + + /// + /// Whether to use minimal URI formatting (only required, or altered properties are included), or full URI formatting. + /// + public bool MinimalUri { get; set; } = true; + + /// + /// The tolerance span for the OTP codes validation. + /// + public ToleranceSpanConfig ToleranceSpan { get; set; } = new(); + + /// + /// Custom properties to place in OTP URIs. + /// + public Dictionary CustomProperties { get; } = []; +} + +/// +/// Configuration for the tolerance span. +/// +public class ToleranceSpanConfig +{ + /// + /// The number of periods/counter values behind the current value. + /// + public int Behind { get; set; } = ToleranceSpan.Default.Behind; + + /// + /// The number of periods/counter values ahead of the current value. + /// + public int Ahead { get; set; } = ToleranceSpan.Default.Ahead; +} diff --git a/libraries/SimpleOTP.DependencyInjection/OtpServiceExtensions.cs b/libraries/SimpleOTP.DependencyInjection/OtpServiceExtensions.cs new file mode 100644 index 0000000..6104b9a --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/OtpServiceExtensions.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace SimpleOTP.DependencyInjection; + +/// +/// Extension methods for the One-Time Password service. +/// +public static class OtpServiceExtensions +{ + /// + /// Adds the One-Time Password service to the service collection. + /// + /// The service collection. + /// The issuer/application/service name. + /// A reference to this instance after the operation has completed. + public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName) => + AddAuthenticator(services, issuerName); + + /// + /// Adds the One-Time Password service to the service collection. + /// + /// The service collection. + /// The issuer/application/service name. + /// The configuration for the One-Time Password service. + /// A reference to this instance after the operation has completed. + public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName, Action? configure = null) + { + OtpOptions options = new() + { + Issuer = issuerName + }; + + configure?.Invoke(options); + services.AddTransient(_ => new OtpService(options)); + return services; + } + + /// + /// Adds the One-Time Password service to the service collection. + /// + /// The service collection. + /// The configuration for the One-Time Password service. + /// A reference to this instance after the operation has completed. + public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration) => + AddAuthenticator(services, configuration); + + /// + /// Adds the One-Time Password service to the service collection. + /// + /// The service collection. + /// The configuration for the One-Time Password service. + /// The configuration for the One-Time Password service. + /// A reference to this instance after the operation has completed. + public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration, Action? configure = null) + { + OtpOptions? options = GetOptionsFromConfiguration(configuration); + + if (options is null) + return services; + + configure?.Invoke(options); + services.AddTransient(_ => new OtpService(options)); + return services; + } + + private static OtpOptions? GetOptionsFromConfiguration(IConfiguration configuration) + { + OtpServiceConfig config = new(); + IConfigurationSection configSection = configuration.GetSection("Authenticator"); + + if (!configSection.Exists() || string.IsNullOrWhiteSpace(configuration["Authenticator:Issuer"])) + return null; + + configSection.Bind(config); + + OtpOptions options = new() + { + Issuer = config.Issuer, + Algorithm = config.Algorithm, + Type = config.Type, + Digits = config.Digits, + Period = config.Period, + IssuerDomain = config.IssuerDomain, + ToleranceSpan = (config.ToleranceSpan.Behind, config.ToleranceSpan.Ahead), + UriFormat = config.UriFormat + }; + + options.UriFormat |= config.MinimalUri ? OtpUriFormat.Minimal : OtpUriFormat.Full; + + foreach (KeyValuePair pair in config.CustomProperties) + options.CustomProperties.Add(pair.Key, pair.Value); + + return options; + } +} diff --git a/libraries/SimpleOTP.DependencyInjection/SimpleOTP.DependencyInjection.csproj b/libraries/SimpleOTP.DependencyInjection/SimpleOTP.DependencyInjection.csproj new file mode 100644 index 0000000..b649b9b --- /dev/null +++ b/libraries/SimpleOTP.DependencyInjection/SimpleOTP.DependencyInjection.csproj @@ -0,0 +1,65 @@ + + + + net8.0 + enable + enable + true + + + + EugeneFox.SimpleOTP.DependencyInjection + 8.0.0.0-rc1 + Eugene Fox + Copyright © Eugene Fox 2024 + en-US + MIT + + + + icon.png + README.md + git + https://github.com/XFox111/SimpleOTP.git + https://github.com/XFox111/SimpleOTP + + + + true + snupkg + + + + + otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth;services;dependency-injection;di + + Dependency Injection implementation for SimpleOTP library. Allows to use SimpleOTP as DI + service in your application. + + + Initial release. See README.md for details. + + + + + + True + + + + True + + + + + + + + + + + + + + diff --git a/libraries/SimpleOTP/AssemblyInfo.cs b/libraries/SimpleOTP/AssemblyInfo.cs new file mode 100644 index 0000000..5113760 --- /dev/null +++ b/libraries/SimpleOTP/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SimpleOTP.Tests")] diff --git a/libraries/SimpleOTP/Converters/OtpAlgorithmJsonConverter.cs b/libraries/SimpleOTP/Converters/OtpAlgorithmJsonConverter.cs new file mode 100644 index 0000000..9b2c5cd --- /dev/null +++ b/libraries/SimpleOTP/Converters/OtpAlgorithmJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SimpleOTP.Converters; + +/// +/// Provides a JSON converter for . +/// +public class OtpAlgorithmJsonConverter : JsonConverter +{ + /// + public override OtpAlgorithm Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString() ?? OtpAlgorithm.SHA1); + + /// + public override void Write(Utf8JsonWriter writer, OtpAlgorithm value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString()); +} diff --git a/libraries/SimpleOTP/Converters/OtpCodeJsonConverter.cs b/libraries/SimpleOTP/Converters/OtpCodeJsonConverter.cs new file mode 100644 index 0000000..bae83ec --- /dev/null +++ b/libraries/SimpleOTP/Converters/OtpCodeJsonConverter.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SimpleOTP.Converters; + +/// +/// Provides a JSON converter for . +/// +public class OtpCodeJsonConverter : JsonConverter +{ + /// + public override OtpCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? code = null; + DateTimeOffset? expirationTime = null; + + while (reader.Read()) + if (reader.TokenType == JsonTokenType.PropertyName) + { + string propertyName = reader.GetString()!; + reader.Read(); + + if (reader.TokenType != JsonTokenType.String) + continue; + + if (propertyName.Equals("Code", StringComparison.OrdinalIgnoreCase)) + code = reader.GetString(); + + if (propertyName.Equals("Expiring", StringComparison.OrdinalIgnoreCase) && + reader.TryGetDateTimeOffset(out DateTimeOffset expiring)) + expirationTime = expiring; + } + + if (code is null) + throw new JsonException("Missing required property 'Code'."); + + return new OtpCode(code, expirationTime); + } + + /// + public override void Write(Utf8JsonWriter writer, OtpCode value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("Code", value.ToString()); + + if (value.ExpirationTime.HasValue) + writer.WriteString("Expiring", value.ExpirationTime.Value); + else + writer.WriteNull("Expiring"); + + writer.WriteEndObject(); + } +} diff --git a/libraries/SimpleOTP/Converters/OtpConfigJsonConverter.cs b/libraries/SimpleOTP/Converters/OtpConfigJsonConverter.cs new file mode 100644 index 0000000..f463233 --- /dev/null +++ b/libraries/SimpleOTP/Converters/OtpConfigJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SimpleOTP.Converters; + +/// +/// Provides a JSON converter for . +/// +public class OtpConfigJsonConverter : JsonConverter +{ + /// + public override OtpConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + OtpConfig.ParseUri(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, OtpConfig value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToUri().AbsoluteUri); +} diff --git a/libraries/SimpleOTP/Converters/OtpSecretJsonConverter.cs b/libraries/SimpleOTP/Converters/OtpSecretJsonConverter.cs new file mode 100644 index 0000000..6802742 --- /dev/null +++ b/libraries/SimpleOTP/Converters/OtpSecretJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SimpleOTP.Converters; + +/// +/// Provides a JSON converter for . +/// +public class OtpSecretJsonConverter : JsonConverter +{ + /// + public override OtpSecret Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, OtpSecret value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString()); +} diff --git a/libraries/SimpleOTP/Encoding/Base32Encoder.cs b/libraries/SimpleOTP/Encoding/Base32Encoder.cs new file mode 100644 index 0000000..c9c04b4 --- /dev/null +++ b/libraries/SimpleOTP/Encoding/Base32Encoder.cs @@ -0,0 +1,120 @@ +namespace SimpleOTP.Encoding; + +/// +/// Provides methods for encoding and decoding data using the RFC 4648 Base32 standard alphabet. +/// +public class Base32Encoder : IEncoder +{ + /// + /// Gets the singleton instance of the class. + /// + public static Base32Encoder Instance { get; } = new(); + + /// + public virtual string Scheme => "base32"; + + /// + /// Converts a byte array to a Base32 string representation. + /// + /// The byte array to convert. + /// The Base32 string representation of the byte array. + /// Thrown when parameter is null. + public string EncodeBytes(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + + if (bytes.Length < 1) + return string.Empty; + + char[] outArray = new char[(int)Math.Ceiling(bytes.Length * 8 / 5d)]; + + int bitIndex = 0; + int buffer = 0; + int filledChars = 0; + + for (int i = 0; i < bytes.Length; i++) + { + if (bitIndex >= 5) + { + outArray[filledChars++] = ValueToChar(buffer >> (bitIndex - 5) & 0x1F); + bitIndex -= 5; + } + + outArray[filledChars++] = ValueToChar(((buffer << (5 - bitIndex)) & 0x1F) | bytes[i] >> (3 + bitIndex)); + buffer = bytes[i]; + bitIndex = 3 + bitIndex; + } + + // Adding trailing bits + if (bitIndex > 0) + outArray[filledChars] = ValueToChar(buffer << (5 - bitIndex) & 0x1F); + + return new string(outArray); + } + + /// + /// Converts a Base32 encoded string to a byte array. + /// + /// The Base32 encoded string to convert. + /// Trailing bits are ignored (e.g. AAAR will be treated as AAAQ - 0x00 0x01). + /// The byte array representation of the Base32 encoded string. + /// Thrown when parameter is null. + /// Thrown when is empty, whitespace, or contains invalid characters. + public byte[] GetBytes(string inArray) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inArray); + + inArray = inArray.TrimEnd('='); + int buffer = 0x00; + int bitIndex = 0; + int filledBytes = 0; + byte[] outArray = new byte[inArray.Length * 5 / 8]; + + for (int i = 0; i < inArray.Length; i++) + { + int value = CharToValue(inArray[i]); + + buffer = (buffer << 5) | value; + bitIndex += 5; + + if (bitIndex >= 8) + { + // We have enough bits to fill a byte, flushing it + outArray[filledBytes++] = (byte)((buffer >> (bitIndex - 8)) & 0xFF); + bitIndex -= 8; + buffer &= 0xFF; // Trimming value to 1 byte to prevent overflow for long strings + } + } + + return outArray; + } + + /// + /// Converts a Base32 character to its numeric value. + /// + /// The Base32 character to convert. + /// The numeric value of the Base32 character. + /// Thrown when is not a valid Base32 character. + protected virtual int CharToValue(char c) => + (int)c switch + { + > 0x31 and < 0x38 => c - 0x18, + > 0x40 and < 0x5B => c - 0x41, + > 0x60 and < 0x7B => c - 0x61, + _ => throw new ArgumentException("Character is not a Base32 character.", nameof(c)), + }; + + /// + /// Converts a numeric value to its Base32 character. + /// + /// The numeric value to convert. + /// The Base32 character corresponding to the numeric value. + /// Thrown when is not a valid Base32 value. + protected virtual char ValueToChar(int value) => + value switch + { + < 26 => (char)(value + 0x41), + < 32 => (char)(value + 0x18), + _ => throw new ArgumentException("Byte is not a Base32 byte.", nameof(value)), + }; +} diff --git a/libraries/SimpleOTP/Encoding/IEncoder.cs b/libraries/SimpleOTP/Encoding/IEncoder.cs new file mode 100644 index 0000000..bb890f8 --- /dev/null +++ b/libraries/SimpleOTP/Encoding/IEncoder.cs @@ -0,0 +1,26 @@ +namespace SimpleOTP.Encoding; + +/// +/// Provides methods for encoding and decoding data using the RFC 4648 Base32 "Extended Hex" alphabet. +/// +public interface IEncoder +{ + /// + /// Gets the encoding scheme used by the encoder (e.g. base32 or base32hex). + /// + public string Scheme { get; } + + /// + /// Converts a byte array to a Base32 string representation. + /// + /// The byte array to convert. + /// The Base32 string representation of the byte array. + public string EncodeBytes(byte[] data); + + /// + /// Converts a Base32 encoded string to a byte array. + /// + /// The Base32 encoded string to convert. + /// The byte array representation of the Base32 encoded string. + public byte[] GetBytes(string data); +} diff --git a/libraries/SimpleOTP/Fluent/OtpBuilder.cs b/libraries/SimpleOTP/Fluent/OtpBuilder.cs new file mode 100644 index 0000000..7d7701b --- /dev/null +++ b/libraries/SimpleOTP/Fluent/OtpBuilder.cs @@ -0,0 +1,40 @@ +namespace SimpleOTP.Fluent; + +/// +/// Class used to streamline OTP code generation on client devices. +/// +public static class OtpBuilder +{ + /// + /// Use TOTP generator with optional counter period. + /// + /// Period in seconds. + /// instance. + public static Otp UseTotp(int period = 30) => + new Totp(OtpSecret.CreateNew(), period); + + /// + /// Use HOTP generator with optional counter value. + /// + /// Counter value. + /// instance. + public static Otp UseHotp(long counter = 0) => + new Hotp(OtpSecret.CreateNew(), counter); + + /// + /// Creates instance from object. + /// + /// object. + /// instance. + public static Otp FromConfig(OtpConfig config) + { + Otp generator = config.Type == OtpType.Totp ? + new Totp(config.Secret, config.Period) : + new Hotp(config.Secret, config.Counter); + + generator.Algorithm = config.Algorithm; + generator.Digits = config.Digits; + + return generator; + } +} diff --git a/libraries/SimpleOTP/Fluent/OtpConfigBuilder.cs b/libraries/SimpleOTP/Fluent/OtpConfigBuilder.cs new file mode 100644 index 0000000..162a479 --- /dev/null +++ b/libraries/SimpleOTP/Fluent/OtpConfigBuilder.cs @@ -0,0 +1,51 @@ +namespace SimpleOTP.Fluent; + +/// +/// Class used to streamline OTP code configuration on client devices. +/// +public static class OtpConfigBuilder +{ + /// + /// Use TOTP configuration with optional counter period. + /// + /// Account name. + /// Period in seconds. + /// instance. + public static OtpConfig UseTotp(string accountName, int period = 30) => + new(accountName) + { + Type = OtpType.Totp, + Period = period + }; + + /// + /// Use HOTP configuration with optional counter. + /// + /// Account name. + /// Counter value. + /// instance. + public static OtpConfig UseHotp(string accountName, long counter = 0) => + new(accountName) + { + Type = OtpType.Hotp, + Counter = counter + }; + + /// + /// Use TOTP which satisfies Apple's specification requirements. + /// + /// Account name. + /// Issuer/application/service display name. + /// Issuer/application/service domain name. + /// instance. + public static OtpConfig UseApple(string accountName, string issuerName, string issuerDomain) => + new(accountName) + { + Type = OtpType.Totp, + Secret = OtpSecret.CreateNew(20), + Digits = 6, + IssuerLabel = issuerName, + Issuer = issuerDomain, + Label = accountName + }; +} diff --git a/libraries/SimpleOTP/Fluent/OtpConfigFluentExtensions.cs b/libraries/SimpleOTP/Fluent/OtpConfigFluentExtensions.cs new file mode 100644 index 0000000..19d92d5 --- /dev/null +++ b/libraries/SimpleOTP/Fluent/OtpConfigFluentExtensions.cs @@ -0,0 +1,118 @@ +namespace SimpleOTP.Fluent; + +/// +/// Provides fluent API for configuring objects. +/// +public static class OtpConfigFluentExtensions +{ + /// + /// Sets the property. + /// + /// The object to configure. + /// The label of the OTP config. + /// The configured object. + public static OtpConfig WithLabel(this OtpConfig config, string label) + { + config.Label = label; + return config; + } + + /// + /// Sets the property. + /// + /// The object to configure. + /// The issuer of the OTP config. + /// The configured object. + public static OtpConfig WithIssuer(this OtpConfig config, string? issuer) + { + config.Issuer = issuer; + return config; + } + + /// + /// Sets the issuer info, according to Apple specification. + /// + /// The object to configure. + /// The display name of the issuer. + /// The domain name of the issuer. + public static OtpConfig WithAppleIssuer(this OtpConfig config, string displayName, string domain) + { + config.IssuerLabel = displayName; + config.Issuer = domain; + return config; + } + + /// + /// Sets the property with a new secret. + /// + /// The object to configure. + /// The length of the secret in bytes. + /// The configured object. + public static OtpConfig WithNewSecret(this OtpConfig config, int bytesLength) + { + config.Secret = OtpSecret.CreateNew(bytesLength); + return config; + } + + /// + /// Sets the property with specified secret. + /// + /// The object to configure. + /// The secret to use. + /// The configured object. + public static OtpConfig WithSecret(this OtpConfig config, OtpSecret secret) + { + config.Secret = secret; + return config; + } + + /// + /// Sets the property. + /// + /// Not recommended for use, since most implementations do not support custom values. + /// The object to configure. + /// The algorithm to use. + /// The configured object. + public static OtpConfig WithAlgorithm(this OtpConfig config, OtpAlgorithm algorithm) + { + config.Algorithm = algorithm; + return config; + } + + /// + /// Sets the property. + /// + /// Not recommended for use, since most implementations do not support custom values. + /// The object to configure. + /// The number of digits to use. + /// The configured object. + public static OtpConfig WithDigits(this OtpConfig config, int digits) + { + config.Digits = digits; + return config; + } + + /// + /// Adds a custom vendor-specific property to the . + /// + /// If set, reserved keys + /// issuer, digits, counter, secret, period and algorithm + /// will be removed from the upon it's serialization to URI. + /// The object to configure. + /// The key of the property. + /// The value of the property. + /// The configured object. + public static OtpConfig AddCustomProperty(this OtpConfig config, string key, string value) + { + config.CustomProperties.Add(key, value); + return config; + } + + /// + /// Creates a new object from the provided + /// + /// The object to use. + /// A new object. + public static Otp CreateGenerator(this OtpConfig config) => + OtpBuilder.FromConfig(config); +} diff --git a/libraries/SimpleOTP/Fluent/OtpFluentExtensions.cs b/libraries/SimpleOTP/Fluent/OtpFluentExtensions.cs new file mode 100644 index 0000000..3f8d670 --- /dev/null +++ b/libraries/SimpleOTP/Fluent/OtpFluentExtensions.cs @@ -0,0 +1,55 @@ +namespace SimpleOTP.Fluent; + +/// +/// Provides fluent API for configuring objects. +/// +public static class OtpFluentExtensions +{ + /// + /// Creates a new object from the provided + /// + /// The object to configure. + /// The length of the secret in bytes. + /// The configured object. + public static Otp WithNewSecret(this Otp generator, int bytesLength) + { + generator.Secret = OtpSecret.CreateNew(bytesLength); + return generator; + } + + /// + /// Creates a new object from the provided + /// + /// The object to configure. + /// The to use. + /// The configured object. + public static Otp WithSecret(this Otp generator, OtpSecret secret) + { + generator.Secret = secret; + return generator; + } + + /// + /// Sets the property. + /// + /// The object to configure. + /// The number of digits to use in OTP codes. + /// The configured object. + public static Otp WithDigits(this Otp generator, int digits) + { + generator.Digits = digits; + return generator; + } + + /// + /// Sets the property. + /// + /// The object to configure. + /// The algorithm to use. + /// The configured object. + public static Otp WithAlgorithm(this Otp generator, OtpAlgorithm algorithm) + { + generator.Algorithm = algorithm; + return generator; + } +} diff --git a/libraries/SimpleOTP/HashAlgorithmProviders.cs b/libraries/SimpleOTP/HashAlgorithmProviders.cs new file mode 100644 index 0000000..e2b0bca --- /dev/null +++ b/libraries/SimpleOTP/HashAlgorithmProviders.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; + +namespace SimpleOTP; + +/// +/// Provides methods for registering and retrieving providers. +/// +public static class HashAlgorithmProviders +{ + private static readonly Dictionary> _registeredProviders = new() + { + { OtpAlgorithm.SHA1, () => new HMACSHA1() }, + { OtpAlgorithm.SHA256, () => new HMACSHA256() }, + { OtpAlgorithm.SHA512, () => new HMACSHA512() }, + { OtpAlgorithm.MD5, () => new HMACMD5() } + }; + + /// + /// Registers a new provider. + /// + /// The algorithm to register. + public static void AddProvider(OtpAlgorithm algorithm) + where TAlgorithm : KeyedHashAlgorithm, new() => + _registeredProviders[algorithm] = () => new TAlgorithm(); + + /// + /// Retrieves a provider. + /// + /// The algorithm to retrieve. + /// The provider, or null if not found. + public static KeyedHashAlgorithm? GetProvider(OtpAlgorithm algorithm) + { + if (_registeredProviders.TryGetValue(algorithm, out var provider)) + return provider(); + + return null; + } + + /// + /// Removes a provider. + /// + /// The algorithm to remove. + public static void RemoveProvider(OtpAlgorithm algorithm) => + _registeredProviders.Remove(algorithm); + + /// + /// Determines whether a provider is registered. + /// + /// The algorithm to check. + /// true if the provider is registered; otherwise, false. + public static bool IsRegistered(OtpAlgorithm algorithm) => + _registeredProviders.ContainsKey(algorithm); + + /// + /// Removes all registered providers. + /// + /// This method also clears default providers. Use with caution. + public static void ClearProviders() => _registeredProviders.Clear(); +} diff --git a/libraries/SimpleOTP/Hotp.cs b/libraries/SimpleOTP/Hotp.cs new file mode 100644 index 0000000..8898f59 --- /dev/null +++ b/libraries/SimpleOTP/Hotp.cs @@ -0,0 +1,60 @@ +namespace SimpleOTP; + +/// +/// Represents a HOTP (HMAC-based One-Time Password) generator. +/// +public class Hotp : Otp +{ + /// + /// Gets or sets the counter value used for generating OTP codes. + /// + public long Counter { get; set; } = 0; + + /// + /// Initializes a new instance of the class + /// + /// The secret key used for generating OTP codes. + public Hotp(OtpSecret secret) : base(secret) { } + + /// + /// Initializes a new instance of the class + /// + /// The secret key used for generating OTP codes. + /// The counter value used for generating OTP codes. + public Hotp(OtpSecret secret, long counter) : base(secret) => + Counter = counter; + + /// + /// Initializes a new instance of the class + /// + /// The secret key used for generating OTP codes. + /// The counter value used for generating OTP codes. + /// The number of digits in the OTP code. + public Hotp(OtpSecret secret, long counter, int digits) : base(secret, digits) => + Counter = counter; + + /// + /// Initializes a new instance of the class + /// + /// The secret key used for generating OTP codes. + /// The counter value used for generating OTP codes. + /// The algorithm used for generating OTP codes. + public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm) : base(secret, algorithm) => + Counter = counter; + + /// + /// Initializes a new instance of the class + /// + /// The secret key used for generating OTP codes. + /// The counter value used for generating OTP codes. + /// The algorithm used for generating OTP codes. + /// The number of digits in the OTP code. + public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) => + Counter = counter; + + /// + /// Gets the current counter value. + /// + /// The current counter value. + protected override long GetCounter() => Counter; +} diff --git a/libraries/SimpleOTP/Otp.cs b/libraries/SimpleOTP/Otp.cs new file mode 100644 index 0000000..d6c200c --- /dev/null +++ b/libraries/SimpleOTP/Otp.cs @@ -0,0 +1,212 @@ +using System.Security.Cryptography; + +namespace SimpleOTP; + +// TODO: Add tests + +/// +/// Represents an abstract class for generating and validating One-Time Passwords (OTP). +/// +public abstract class Otp +{ + #region Properties + + /// + /// Gets or sets the secret key used for generating OTPs. + /// + public OtpSecret Secret { get; set; } + + /// + /// Gets or sets the algorithm used for generating OTP codes. + /// + public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1; + + /// + /// Gets or sets the number of digits in the OTP code. + /// + /// Default: 6. Recommended: 6-8. + public int Digits { get; set; } = 6; + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The secret key used for generating OTP codes. + /// The algorithm used for generating OTP codes. + /// The number of digits in the OTP code. + public Otp(OtpSecret secret, OtpAlgorithm algorithm, int digits) => + (Secret, Algorithm, Digits) = (secret, algorithm, digits); + + /// + /// Initializes a new instance of the class. + /// + /// The secret key used for generating OTP codes. + /// The algorithm used for generating OTP codes. + public Otp(OtpSecret secret, OtpAlgorithm algorithm) : this(secret, algorithm, 6) { } + + /// + /// Initializes a new instance of the class. + /// + /// The secret key used for generating OTP codes. + /// The number of digits in the OTP code. + public Otp(OtpSecret secret, int digits) : this(secret, OtpAlgorithm.SHA1, digits) { } + + /// + /// Initializes a new instance of the class. + /// + /// The secret key used for generating OTP codes. + public Otp(OtpSecret secret) : this(secret, OtpAlgorithm.SHA1, 6) { } + + #endregion + + #region Methods + + // Generate + + /// + /// Generates an OTP code. + /// + /// The generated OTP code. + public OtpCode Generate() => + Generate(GetCounter()); + + /// + /// Generates an OTP code for the specified counter value. + /// + /// The counter value to generate the OTP code for. + /// The generated OTP code. + public virtual OtpCode Generate(long counter) => + new(Compute(counter), Digits); + + // Validate + + /// + /// Validates an OTP code. + /// + /// The OTP code to validate. + /// true if the OTP code is valid; otherwise, false. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + public bool Validate(OtpCode code) => + Validate(code, (1, 1)); + + /// + /// Validates an OTP code with tolerance. + /// + /// The OTP code to validate. + /// The tolerance span for code validation. + /// true if the OTP code is valid; otherwise, false. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + public bool Validate(OtpCode code, ToleranceSpan tolerance) => + Validate(code, tolerance, out _); + + /// + /// Validates an OTP code with tolerance and returns the resynchronization value. + /// + /// The OTP code to validate. + /// The tolerance span for code validation. + /// The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value. + /// true if the OTP code is valid; otherwise, false. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + public bool Validate(OtpCode code, ToleranceSpan tolerance, out int resyncValue) => + Validate(code, tolerance, GetCounter(), out resyncValue); + + /// + /// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value. + /// + /// The OTP code to validate. + /// The tolerance span for code validation. + /// The base counter value. + /// The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value. + /// true if the OTP code is valid; otherwise, false. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + public bool Validate(OtpCode code, ToleranceSpan tolerance, long baseCounter, out int resyncValue) + { + resyncValue = 0; + + using KeyedHashAlgorithm? hashAlgorithm = HashAlgorithmProviders.GetProvider(Algorithm) ?? + throw new InvalidOperationException($"Implementation for the \"{Algorithm}\" algorithm was not found."); + + for (int i = -tolerance.Behind; i <= tolerance.Ahead; i++) + if (code == Compute(baseCounter + i, hashAlgorithm).ToString($"D{Digits}")) + { + resyncValue = i; + return true; + } + + return false; + } + + /// + /// Gets the current counter value. + /// + /// The current counter value. + protected abstract long GetCounter(); + + /// + /// Computes the OTP code for the specified counter value. + /// + /// The counter value to compute the OTP code for. + /// The OTP code for the specified counter value. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + protected int Compute(long counter) + { + using KeyedHashAlgorithm? hashAlgorithm = HashAlgorithmProviders.GetProvider(Algorithm) ?? + throw new InvalidOperationException($"Implementation for the \"{Algorithm}\" algorithm was not found."); + + return Compute(counter, hashAlgorithm); + } + + /// + /// Computes the OTP code for the specified counter value using provided hash algorithm. + /// + /// The counter value to compute the OTP code for. + /// The hash algorithm to use for computing the OTP code. + /// You need to dispose of the object yourself when you are done using it. + /// The OTP code for the specified counter value. + protected virtual int Compute(long counter, KeyedHashAlgorithm hashAlgorithm) + { + byte[] counterBytes = BitConverter.GetBytes(counter); + + // "The HOTP values generated by the HOTP generator are treated as big endian." + // https://datatracker.ietf.org/doc/html/rfc4226#section-5.2 + if (BitConverter.IsLittleEndian) + Array.Reverse(counterBytes); + + hashAlgorithm.Key = Secret; + + byte[] hash = hashAlgorithm.ComputeHash(counterBytes); + + // Converting hash to n-digits value + // See RFC4226 Section 5.4 for more details + // https://datatracker.ietf.org/doc/html/rfc4226#section-5.4 + int offset = hash[^1] & 0x0F; + + int value = + (hash[offset + 0] & 0x7F) << 24 | // Result value should be a 31-bit integer, hence the 0x7F (0111 1111) + (hash[offset + 1] & 0xFF) << 16 | + (hash[offset + 2] & 0xFF) << 8 | + (hash[offset + 3] & 0xFF) << 0; + + return value % (int)Math.Pow(10, Digits); + } + + #endregion +} diff --git a/libraries/SimpleOTP/OtpAlgorithm.cs b/libraries/SimpleOTP/OtpAlgorithm.cs new file mode 100644 index 0000000..e732a76 --- /dev/null +++ b/libraries/SimpleOTP/OtpAlgorithm.cs @@ -0,0 +1,151 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; +using SimpleOTP.Converters; + +namespace SimpleOTP; + +/// +/// Represents the hashing algorithm used for One-Time Passwords. +/// +[Serializable] +[JsonConverter(typeof(OtpAlgorithmJsonConverter))] +public readonly partial struct OtpAlgorithm : IEquatable, IEquatable, IXmlSerializable +{ + /// + /// The HMAC-SHA1 hashing algorithm. + /// + public static OtpAlgorithm SHA1 { get; } = new("SHA1"); + + /// + /// The HMAC-SHA256 hashing algorithm. + /// + public static OtpAlgorithm SHA256 { get; } = new("SHA256"); + + /// + /// The HMAC-SHA512 hashing algorithm. + /// + public static OtpAlgorithm SHA512 { get; } = new("SHA512"); + + /// + /// The HMAC-MD5 hashing algorithm. + /// + /// + /// This is not a standard algorithm, but it is defined by IIJ specification and recognized by default.
+ /// Internet Initiative Japan. URI format + ///
+ public static OtpAlgorithm MD5 { get; } = new("MD5"); + + private readonly string _value; + + /// + /// Initializes a new instance of the struct. + /// + /// The algorithm to use. + /// Thrown if is empty or whitespace. + /// Thrown if is . + public OtpAlgorithm(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + if (StandardAlgorithmsRegex().IsMatch(value)) + _value = StandardAlgorithmsRegex().Match(value).Value.ToUpperInvariant(); + else + _value = value.ToUpperInvariant(); + } + + /// + public bool Equals(OtpAlgorithm other) => + _value == other._value; + + /// + public bool Equals(string? other) + { + if (string.IsNullOrWhiteSpace(other)) + return _value is null; + if (_value is null) + return false; + + return Equals(new OtpAlgorithm(other)); + } + + /// + public override bool Equals(object? obj) => + obj is OtpAlgorithm algorithm && Equals(algorithm); + + /// + /// Determines whether the specified is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512). + /// + /// if the specified is standard; otherwise, . + public bool IsStandard() => + IsStandard(_value); + + /// + public override int GetHashCode() => _value.GetHashCode(); + + /// + /// Returns the string representation of the struct. + /// + /// The string representation of the struct. + public override string ToString() => _value; + + /// + public XmlSchema? GetSchema() => null; + + /// + public void ReadXml(XmlReader reader) + { + reader.MoveToContent(); + + if (reader.NodeType != XmlNodeType.Element) + throw new XmlException("Invalid XML element."); + + string algorithm = reader.ReadElementContentAsString(); + +#pragma warning disable CS9195 // Argument should be passed with the in keyword + Unsafe.AsRef(this) = new OtpAlgorithm(algorithm); +#pragma warning restore CS9195 // Argument should be passed with the in keyword + } + + /// + public void WriteXml(XmlWriter writer) + { + writer.WriteAttributeString("standard", IsStandard().ToString().ToLowerInvariant()); + writer.WriteString(_value); + } + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static bool operator ==(OtpAlgorithm left, OtpAlgorithm right) => left.Equals(right); + public static bool operator ==(string left, OtpAlgorithm right) => right.Equals(left); + public static bool operator ==(OtpAlgorithm left, string right) => left.Equals(right); + + public static bool operator !=(OtpAlgorithm left, OtpAlgorithm right) => !(left == right); + public static bool operator !=(string left, OtpAlgorithm right) => !(left == right); + public static bool operator !=(OtpAlgorithm left, string right) => !(left == right); + + public static implicit operator string(OtpAlgorithm algorithm) => algorithm._value; + public static explicit operator OtpAlgorithm(string value) => new(value); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Determines whether the specified is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512). + /// + /// The algorithm to check. + /// if the specified is standard; otherwise, . + public static bool IsStandard(string algorithm) => + StandardAlgorithmsRegex().IsMatch(algorithm); + + /// + /// Determines whether the specified is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512). + /// + /// The algorithm to check. + /// if the specified is standard; otherwise, . + public static bool IsStandard(OtpAlgorithm algorithm) => + IsStandard(algorithm._value); + + [GeneratedRegex(@"(?<=Hmac)?SHA(1|256|512)", RegexOptions.IgnoreCase, "")] + private static partial Regex StandardAlgorithmsRegex(); +} diff --git a/libraries/SimpleOTP/OtpCode/OtpCode.Base.cs b/libraries/SimpleOTP/OtpCode/OtpCode.Base.cs new file mode 100644 index 0000000..35172bf --- /dev/null +++ b/libraries/SimpleOTP/OtpCode/OtpCode.Base.cs @@ -0,0 +1,111 @@ +namespace SimpleOTP; + +// THIS IS THE BASE OF A PARTIAL STRUCT +// List of files +// - OtpCode.Base.cs - Base file +// - OtpCode.Static.cs - Static members +// - OtpCode.Serialization.cs - JSON/XML serialization members and attributes + +/// +/// Represents a one-time password (OTP) code. +/// +public readonly partial struct OtpCode : IEquatable, IEquatable +{ + private readonly int _value; + private readonly int _digits; + + /// + /// Gets a value indicating whether the OTP code can expire (true for TOTP, false for HOTP). + /// + public readonly bool CanExpire => ExpirationTime is not null; + + /// + /// Gets the expiration time of the OTP code (TOTP only). + /// + public readonly DateTimeOffset? ExpirationTime { get; } + + /// + /// Initializes a new instance of the struct with the specified value with no expiration time. + /// + /// The value of the OTP code. + /// The number of digits in the OTP code. + /// is . + /// is not a valid numeric code. + public OtpCode(int code, int digits) : this(code, digits, null) { } + + /// + /// Initializes a new instance of the struct with the specified value and the expiration time. + /// + /// The value of the OTP code. + /// The number of digits in the OTP code. + /// The expiration time of the OTP code (TOTP only). + /// is . + /// is not a valid numeric code. + public OtpCode(int code, int digits, DateTimeOffset? expirationTime = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(code); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(digits); + + _value = code; + _digits = digits; + ExpirationTime = expirationTime; + } + + /// + /// Initializes a new instance of the struct with the specified value with no expiration time. + /// + /// The value of the OTP code. + /// is . + /// is not a valid numeric code. + public OtpCode(string code) : this(code, null) { } + + /// + /// Initializes a new instance of the struct with the specified value and the expiration time. + /// + /// The value of the OTP code. + /// The expiration time of the OTP code (TOTP only). + /// is . + /// is not a valid numeric code. + public OtpCode(string code, DateTimeOffset? expirationTime = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + + if (!int.TryParse(code, out var value)) + throw new ArgumentException($"'{code}' is not a valid numeric code.", nameof(code)); + + _value = value; + _digits = code.Length; + ExpirationTime = expirationTime; + } + + /// + /// Returns a string representation of the OTP code. + /// + /// A string representation of the OTP code. + public override readonly string ToString() => + _value.ToString($"D{_digits}"); + + /// + /// Returns a string representation of the OTP code. + /// + /// The format to use. + /// The string representation of the OTP code. + public readonly string ToString(string? format) => + _value.ToString(format); + + /// + public bool Equals(OtpCode other) => + ToString() == other.ToString(); + + /// + public override bool Equals(object? obj) => + obj is OtpCode code && Equals(code); + + /// + public bool Equals(string? other) => + ToString() == other; + + /// + public override int GetHashCode() => + _value.GetHashCode(); +} diff --git a/libraries/SimpleOTP/OtpCode/OtpCode.Serialization.cs b/libraries/SimpleOTP/OtpCode/OtpCode.Serialization.cs new file mode 100644 index 0000000..dbb60a5 --- /dev/null +++ b/libraries/SimpleOTP/OtpCode/OtpCode.Serialization.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; +using SimpleOTP.Converters; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL STRUCT +// Description: Section of OtpCode struct that holds JSON/XML serialization members and attributes +// Base file: OtpCode.Base.cs + +[Serializable] +[JsonConverter(typeof(OtpCodeJsonConverter))] +public readonly partial struct OtpCode : IXmlSerializable +{ + /// + public XmlSchema? GetSchema() => null; + + /// + public void ReadXml(XmlReader reader) + { + reader.MoveToContent(); + + if (reader.NodeType != XmlNodeType.Element) + throw new XmlException("Invalid XML element."); + + DateTimeOffset? expirationTime = null; + + if (reader.HasAttributes && reader.MoveToAttribute("expiring")) + expirationTime = DateTimeOffset.ParseExact(reader.ReadContentAsString(), "O", null); + + reader.MoveToContent(); + + if (reader.NodeType != XmlNodeType.Element) + throw new XmlException("Invalid XML content."); + + string code = reader.ReadElementContentAsString(); + +#pragma warning disable CS9195 // Argument should be passed with the in keyword + Unsafe.AsRef(this) = new OtpCode(code, expirationTime); +#pragma warning restore CS9195 // Argument should be passed with the in keyword + } + + /// + public void WriteXml(XmlWriter writer) + { + if (ExpirationTime.HasValue) + writer.WriteAttributeString("expiring", ExpirationTime.Value.ToString("O")); + + writer.WriteString(ToString()); + } +} diff --git a/libraries/SimpleOTP/OtpCode/OtpCode.Static.cs b/libraries/SimpleOTP/OtpCode/OtpCode.Static.cs new file mode 100644 index 0000000..3fe73df --- /dev/null +++ b/libraries/SimpleOTP/OtpCode/OtpCode.Static.cs @@ -0,0 +1,51 @@ +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL STRUCT +// Description: Section of OtpCode struct that holds static members +// Base file: OtpCode.Base.cs + +public readonly partial struct OtpCode +{ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static implicit operator string(OtpCode code) => code.ToString(); + public static implicit operator OtpCode(string code) => new(code); + + public static bool operator ==(OtpCode left, OtpCode right) => left.Equals(right); + public static bool operator ==(string left, OtpCode right) => right.Equals(left); + public static bool operator ==(OtpCode left, string right) => left.Equals(right); + + public static bool operator !=(OtpCode left, OtpCode right) => !(left == right); + public static bool operator !=(string left, OtpCode right) => !(left == right); + public static bool operator !=(OtpCode left, string right) => !(left == right); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Parses the specified into an object. + /// + /// The string to parse. + /// An object. + /// is . + /// is not a valid numeric code. + public static OtpCode Parse(string code) => + new(code); + + /// + /// Tries to parse the specified into an object. + /// + /// The string to parse. + /// The parsed object. + /// if was parsed successfully; otherwise, . + public static bool TryParse(string code, out OtpCode result) + { + try + { + result = new(code); + return true; + } + catch + { + result = default; + return false; + } + } +} diff --git a/libraries/SimpleOTP/OtpConfig/OtpConfig.Base.cs b/libraries/SimpleOTP/OtpConfig/OtpConfig.Base.cs new file mode 100644 index 0000000..274a65e --- /dev/null +++ b/libraries/SimpleOTP/OtpConfig/OtpConfig.Base.cs @@ -0,0 +1,130 @@ +using System.Collections.Specialized; + +namespace SimpleOTP; + +// THIS IS THE BASE OF A PARTIAL CLASS +// List of files +// - OtpConfig.Base.cs - Base file +// - OtpConfig.Constructors.cs - Instance constructors +// - OtpConfig.Methods.cs - Instance methods and serialization +// - OtpConfig.Static.cs - Static members + +/// +/// Represents the configuration for a One-Time Password (OTP). +/// +public partial record class OtpConfig +{ + /// + /// Gets or sets the type of the OTP. + /// + /// Default is: + /// + /// Internet-Draft.
+ /// IMPORTANT: Some authenticators do not support . + ///
+ public OtpType Type { get; set; } = OtpType.Totp; + + /// + /// Gets or sets the issuer label prefix of the OTP. + /// + /// + /// + /// Not recommended for use in most cases. + /// Most authenticators do not support this prefix and mess with the string. + /// Required if you intend to use . Use this prefix to set the issuer display name. + /// + /// Internet-Draft. + /// + public string? IssuerLabel { get; set; } + + /// + /// Gets or sets the label of the OTP. + /// + /// + /// Internet-Draft. + /// + public string Label { get; set; } + + /// + /// Gets or sets the secret of the OTP. + /// + /// Default: 160-bit key. Minimal recommended: 128 bits + /// + /// Internet-Draft + /// + public OtpSecret Secret { get; set; } = OtpSecret.CreateNew(); + + /// + /// Gets or sets the hashing algorithm of the OTP. + /// + /// Default: + /// + /// Internet-Draft
+ /// IMPORTANT: Some authenticators do not support algorithms other than . + ///
+ public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1; + + /// + /// Gets or sets the issuer of the OTP. Optional. + /// + /// + /// + /// Use this property instead of . + /// Required if you intend to use . Use this property to set the issuer domain name. + /// + /// Internet-Draft + /// + public string? Issuer { get; set; } + + /// + /// Gets or sets the number of digits of the OTP codes. + /// + /// Default: 6. Recommended: 6 or 8 + /// + /// Internet-Draft
+ /// IMPORTANT: Some authenticators do not support digits other than 6. + ///
+ public int Digits { get; set; } = 6; + + /// + /// Gets or sets the counter of the OTP. Required for . Ignored for . + /// + /// Default: 0 + /// + /// Internet-Draft
+ /// IMPORTANT: Some authenticators do not support . + ///
+ public long Counter { get; set; } = 0; + + /// + /// Gets or sets the period of the OTP in seconds. Optional for . Ignored for . + /// + /// Default: 30 + /// + /// Internet-Draft
+ /// IMPORTANT: Some authenticators support only periods of 30 seconds. + ///
+ public int Period { get; set; } = 30; + + /// + /// Gets the custom vendor-specified properties of the current OTP configuration. + /// + /// + /// If set, reserved keys + /// issuer, digits, counter, secret, period and algorithm + /// will be removed from the upon it's serialization to URI.
+ /// Internet-Draft + ///
+ public NameValueCollection CustomProperties { get; } = []; + + // Reserved keys, which are to be removed for CustomProperties + private static readonly string[] _reservedKeys = + [ + nameof(Issuer), + nameof(Digits), + nameof(Counter), + nameof(Secret), + nameof(Period), + nameof(Algorithm) + ]; +} diff --git a/libraries/SimpleOTP/OtpConfig/OtpConfig.Constructors.cs b/libraries/SimpleOTP/OtpConfig/OtpConfig.Constructors.cs new file mode 100644 index 0000000..833c43b --- /dev/null +++ b/libraries/SimpleOTP/OtpConfig/OtpConfig.Constructors.cs @@ -0,0 +1,104 @@ +using System.Collections.Specialized; +using System.Net; +using System.Web; +using SimpleOTP.Encoding; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL CLASS +// Description: Section of OtpConfig struct that holds instance constructors +// Base file: OtpConfig.Base.cs + +public partial record class OtpConfig +{ + /// + /// Initializes a new instance of the class. + /// + /// The label of the OTP config. + public OtpConfig(string label) => + Label = label; + + /// + /// Initializes a new instance of the class. + /// + /// The label of the OTP config. + /// The issuer of the OTP config. + public OtpConfig(string label, string issuer) => + (Label, Issuer) = (label, issuer); + + /// + /// Initializes a new instance of the class. + /// + /// The label of the OTP config. + /// The secret of the OTP config. + public OtpConfig(string label, OtpSecret secret) => + (Label, Secret) = (label, secret); + + /// + /// Initializes a new instance of the class. + /// + /// The label of the OTP config. + /// The issuer of the OTP config. + /// The secret of the OTP config. + public OtpConfig(string label, string issuer, OtpSecret secret) => + (Label, Issuer, Secret) = (label, issuer, secret); + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the OTP config. + /// Provided URI is not valid (missing required values or has invalid required values). + public OtpConfig(Uri uri) : this(uri, OtpSecret.DefaultEncoder) { } + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the OTP config. + /// The encoder used to decode the secret. + /// Provided URI is not valid (missing required values or has invalid required values). + public OtpConfig(Uri uri, IEncoder encoder) + { + if (uri.Scheme != "otpauth" && uri.Scheme != "apple-otpauth") + throw new ArgumentException("Invalid URI scheme. Expected 'otpauth' or 'apple-otpauth'."); + + Type = uri.Host.ToLowerInvariant() switch + { + "totp" => OtpType.Totp, + "hotp" => OtpType.Hotp, + _ => throw new ArgumentException("Invalid OTP type. Expected 'totp' or 'hotp'.") + }; + + NameValueCollection query = HttpUtility.ParseQueryString(uri.Query); + + Secret = OtpSecret.Parse(query[nameof(Secret)] ?? throw new ArgumentException("Secret is required."), encoder); + + string label = WebUtility.UrlDecode(uri.Segments[^1]); + string[] labelParts = label.Split(':', 2, StringSplitOptions.RemoveEmptyEntries); + Label = labelParts.Last(); + + if (labelParts.Length > 1) + IssuerLabel = labelParts[0]; + + if (long.TryParse(query[nameof(Counter)], out long counter) || Type != OtpType.Hotp) + Counter = counter; + else + throw new ArgumentException("Counter is required for HOTP algorithm."); + + if (query.Get(nameof(Issuer)) is string issuer) + Issuer = issuer; + + if (query.Get(nameof(Algorithm)) is string algorithm && !string.IsNullOrWhiteSpace(algorithm)) + Algorithm = (OtpAlgorithm)algorithm; + + if (int.TryParse(query[nameof(Period)], out int period)) + Period = period; + + if (int.TryParse(query[nameof(Digits)], out int digits)) + Digits = digits; + + foreach (string key in _reservedKeys) + query.Remove(key); + + CustomProperties.Add(query); + } +} diff --git a/libraries/SimpleOTP/OtpConfig/OtpConfig.Methods.cs b/libraries/SimpleOTP/OtpConfig/OtpConfig.Methods.cs new file mode 100644 index 0000000..78390f2 --- /dev/null +++ b/libraries/SimpleOTP/OtpConfig/OtpConfig.Methods.cs @@ -0,0 +1,112 @@ +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Web; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; +using SimpleOTP.Converters; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL CLASS +// Description: Section of OtpConfig struct that holds instance methods +// Base file: OtpConfig.Base.cs + +[Serializable] +[JsonConverter(typeof(OtpConfigJsonConverter))] +public partial record class OtpConfig : IXmlSerializable +{ + /// + /// Converts the current object to a object. + /// + /// + /// Uses minimal Google specified formatting ( | ). + /// + /// A object representing the current object. + public Uri ToUri() => + ToUri(OtpUriFormat.Minimal | OtpUriFormat.Google); + + /// + /// Converts the current object to a object. + /// + /// A bitwise combination of the enumeration values that specifies the format of the URI. + /// A object representing the current object. + public Uri ToUri(OtpUriFormat format) + { + string scheme = format.HasFlag(OtpUriFormat.Apple) ? "apple-otpauth" : "otpauth"; + string label = HttpUtility.UrlEncode(Label).Replace("+", "%20"); + + if (!string.IsNullOrWhiteSpace(IssuerLabel)) + label = $"{HttpUtility.UrlEncode(IssuerLabel).Replace("+", "%20")}:{label}"; + + UriBuilder uri = new(scheme, Type.ToString().ToLowerInvariant(), -1, label); + NameValueCollection query = HttpUtility.ParseQueryString(uri.Query); + + query["secret"] = Secret.ToString(); + + if (Type == OtpType.Hotp) + query["counter"] = Counter.ToString(); + + if (Issuer is not null) + query["issuer"] = Issuer; + + if (format.HasFlag(OtpUriFormat.Full) || !Algorithm.Equals(OtpAlgorithm.SHA1)) + { + if (format.HasFlag(OtpUriFormat.IBM) && Algorithm.IsStandard()) + query["algorithm"] = "Hmac" + Algorithm; + else + query["algorithm"] = Algorithm; + } + + if (format.HasFlag(OtpUriFormat.Full) || Digits != 6) + query["digits"] = Digits.ToString(); + + if (format.HasFlag(OtpUriFormat.Full) || Period != 30) + query["period"] = Period.ToString(); + + foreach (string key in _reservedKeys) + CustomProperties.Remove(key); + + query.Add(CustomProperties); + + uri.Query = query.ToString(); + return uri.Uri; + } + + /// + /// Returns if the specified object is valid. + /// + /// The error message returned if the object is invalid. + /// The to use for validation. + /// true if the conversion succeeded; otherwise, false. + public bool IsValid([NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google) => + Validate(this, out error, format); + + /// + public override string ToString() => + ToUri().AbsoluteUri; + + /// + public XmlSchema? GetSchema() => null; + + /// + public void ReadXml(XmlReader reader) + { + reader.MoveToContent(); + + if (reader.NodeType != XmlNodeType.Element) + throw new XmlException("Invalid XML element."); + +#pragma warning disable CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter + Unsafe.AsRef(this) = ParseUri(reader.ReadElementContentAsString()); +#pragma warning restore CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter + } + + /// + public void WriteXml(XmlWriter writer) + { + writer.WriteString(ToUri().AbsoluteUri); + } +} diff --git a/libraries/SimpleOTP/OtpConfig/OtpConfig.Static.cs b/libraries/SimpleOTP/OtpConfig/OtpConfig.Static.cs new file mode 100644 index 0000000..7112c0c --- /dev/null +++ b/libraries/SimpleOTP/OtpConfig/OtpConfig.Static.cs @@ -0,0 +1,154 @@ +using System.Diagnostics.CodeAnalysis; +using SimpleOTP.Encoding; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL CLASS +// Description: Section of OtpConfig struct that holds static members +// Base file: OtpConfig.Base.cs + +public partial record class OtpConfig +{ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static implicit operator Uri(OtpConfig config) => config.ToUri(); + public static implicit operator string(OtpConfig config) => config.ToString(); + + public static explicit operator OtpConfig(Uri uri) => ParseUri(uri); + public static explicit operator OtpConfig(string uri) => ParseUri(uri); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Parses the specified URI into an object. + /// + /// The URI to parse. + /// An object parsed from the specified URI. + /// Provided URI is not valid (missing required values or has invalid required values). + public static OtpConfig ParseUri(Uri uri) => + new(uri); + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the OTP. + /// The encoder used to decode the secret. + /// Provided URI is not valid (missing required values or has invalid required values). + public static OtpConfig ParseUri(Uri uri, IEncoder encoder) => + new(uri, encoder); + + /// + /// Parses the specified URI into an object. + /// + /// The URI to parse. + /// An object parsed from the specified URI. + /// Provided URI is not valid (missing required values or has invalid required values). + /// Provided URI is not valid (missing required values or has invalid required values). + public static OtpConfig ParseUri(string uri) => + new(new Uri(uri)); + + /// + /// Tries to parse the specified URI into an object. + /// + /// The URI to parse. + /// When this method returns, contains the object parsed from the specified URI, if the conversion succeeded, or null if the conversion failed. + /// true if the conversion succeeded; otherwise, false. + public static bool TryParseUri(Uri uri, [NotNullWhen(true)] out OtpConfig? config) + { + try + { + config = new(uri); + return true; + } + catch + { + config = null; + return false; + } + } + + /// + /// Tries to parse the specified URI into an object. + /// + /// The URI to parse. + /// When this method returns, contains the object parsed from the specified URI, if the conversion succeeded, or null if the conversion failed. + /// true if the conversion succeeded; otherwise, false. + public static bool TryParseUri(string uri, [NotNullWhen(true)] out OtpConfig? config) => + TryParseUri(new Uri(uri), out config); + + /// + /// Returns if the specified object is valid. + /// + /// The object to validate. + /// The error message returned if the object is invalid. + /// The to use for validation. + /// The should contain at least one vendor-specific format. + /// true if the conversion succeeded; otherwise, false. + public static bool Validate(OtpConfig config, [NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google) + { + List errors = []; + + // Check label presence + if (string.IsNullOrWhiteSpace(config.Label)) + errors.Add($"- '{nameof(config.Label)}' is required and must be a display name for the account."); + + if ((format.HasFlag(OtpUriFormat.Apple) || format.HasFlag(OtpUriFormat.IIJ)) && config.Type != OtpType.Totp) + errors.Add($"- '{nameof(config.Type)}' must be '{OtpType.Totp}'."); + + // Vendor-specific formats validation + if (format.HasFlag(OtpUriFormat.Apple)) + { + if (string.IsNullOrWhiteSpace(config.Issuer) || Uri.CheckHostName(config.Issuer) != UriHostNameType.Dns) + errors.Add($"- '{nameof(config.Issuer)}' is required and must be a valid DNS name."); + + if (string.IsNullOrWhiteSpace(config.IssuerLabel)) + errors.Add($"- '{nameof(config.IssuerLabel)}' is required and must be a display name for the issuer."); + + if (((byte[])config.Secret).Length < 20) + errors.Add($"- '{nameof(config.Secret)}' is required and must be at least 20 bytes long."); + } + + // All vendors, except Apple reccommend that + // if issuer label is specified, it should be the same as the issuer + if (!format.HasFlag(OtpUriFormat.Apple) && + !string.IsNullOrWhiteSpace(config.IssuerLabel) && config.IssuerLabel != config.Issuer) + errors.Add($"- (optional) '{nameof(config.IssuerLabel)}' should be the same as '{nameof(config.Issuer)}'."); + + if (format.HasFlag(OtpUriFormat.Yubico)) + { + if (config.Type == OtpType.Totp && config.Period is not 15 or 30 or 60) + errors.Add($"- '{nameof(config.Period)}' must be 15, 30 or 60."); + } + + // Check for digits value + if (config.Digits is not 6 or 8) + { + // Now it's time for IBM and Yubico to be weird + if (format.HasFlag(OtpUriFormat.IBM) && config.Digits is not 7 and not 9) + errors.Add($"- '{nameof(config.Digits)}' must be 6-9."); + + if (format.HasFlag(OtpUriFormat.Yubico) && config.Digits is not 7) + errors.Add($"- '{nameof(config.Digits)}' must be 6-8."); + + else + errors.Add($"- '{nameof(config.Digits)}' must be 6 or 8."); + } + + // Algorithm validation + if (!config.Algorithm.IsStandard()) + { + // IIJ can also have an MD5 algorithm + if (format.HasFlag(OtpUriFormat.IIJ) && config.Algorithm != OtpAlgorithm.MD5) + errors.Add($"- '{nameof(config.Algorithm)}' must be a standard algorithm, defined by IIJ (SHA1/256/512 or MD5)."); + else + errors.Add($"- '{nameof(config.Algorithm)}' must be a standard algorithm (SHA1, SHA256 or SHA512)."); + } + + if (errors.Count > 0) + { + error = string.Join("\n", errors); + return false; + } + + error = null; + return true; + } +} diff --git a/libraries/SimpleOTP/OtpSecret/OtpSecret.Base.cs b/libraries/SimpleOTP/OtpSecret/OtpSecret.Base.cs new file mode 100644 index 0000000..906621f --- /dev/null +++ b/libraries/SimpleOTP/OtpSecret/OtpSecret.Base.cs @@ -0,0 +1,109 @@ +using System.Numerics; +using System.Security.Cryptography; +using System.Xml.Serialization; +using SimpleOTP.Encoding; + +namespace SimpleOTP; + +// THIS IS THE BASE OF A PARTIAL CLASS +// List of files +// - OtpSecret.Base.cs - Base file +// - OtpSecret.Static.cs - Static members +// - OtpSecret.Serialization.cs - JSON/XML serialization members and attributes + +/// +/// Represents a one-time password secret. +/// +public partial class OtpSecret : IEquatable, IEquatable, IXmlSerializable, IDisposable +{ + private readonly byte[] _secret; + + /// + /// Initializes a new instance of the class with a default length of 20 bytes (160 bits). + /// + public OtpSecret() : this(20) { } + + /// + /// Initializes a new instance of the class with a random secret of the specified length. + /// + /// + /// 20 bytes (160 bits) is the recommended key length specified by RFC 4226. + /// Minimal recommended length is 16 bytes (128 bits). + /// + /// The length of the secret in bytes. + /// is less than 1. + public OtpSecret(int length) => + _secret = RandomNumberGenerator.GetBytes(length); + + /// + /// Initializes a new instance of the class from a byte array. + /// + /// The byte array. + /// is null or empty. + public OtpSecret(byte[] secret) + { + if (secret is null || secret.Length < 1) + throw new ArgumentNullException(nameof(secret)); + + _secret = secret; + } + + /// + /// Initializes a new instance of the class from a Base32-encoded string (RFC 4648 §6). + /// + /// The Base32-encoded string. + /// is null. + /// is empty or contains invalid characters or only whitespace. + public OtpSecret(string secret) : this(secret, DefaultEncoder) { } + + /// + /// Initializes a new instance of the class from an encoded string. + /// + /// The encoded string. + /// The encoder. + /// is null or empty. + /// is empty or contains invalid characters or only whitespace. + public OtpSecret(string secret, IEncoder encoder) + { + ArgumentException.ThrowIfNullOrWhiteSpace(secret, nameof(secret)); + _secret = encoder.GetBytes(secret); + } + + /// + /// Returns the Base32-encoded string representation of the current object. + /// + /// The Base32-encoded string representation of the current object. + public override string ToString() => + DefaultEncoder.EncodeBytes(_secret); + + /// + /// Returns the string representation of the current object. + /// + /// The encoder. + /// The string representation of the current object. + public string ToString(IEncoder encoder) => + encoder.EncodeBytes(_secret); + + /// + public bool Equals(OtpSecret? other) => + other is not null && _secret.SequenceEqual(other._secret); + + /// + public override bool Equals(object? obj) => + obj is OtpSecret other && Equals(other); + + /// + public bool Equals(byte[]? other) => + other is not null && _secret.SequenceEqual(other); + + /// + public override int GetHashCode() => + new BigInteger(_secret ?? []).GetHashCode(); + + /// + public void Dispose() + { + Array.Clear(_secret, 0, _secret.Length); + GC.SuppressFinalize(this); + } +} diff --git a/libraries/SimpleOTP/OtpSecret/OtpSecret.Serialization.cs b/libraries/SimpleOTP/OtpSecret/OtpSecret.Serialization.cs new file mode 100644 index 0000000..ba2727d --- /dev/null +++ b/libraries/SimpleOTP/OtpSecret/OtpSecret.Serialization.cs @@ -0,0 +1,42 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Xml; +using System.Xml.Schema; +using SimpleOTP.Converters; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL CLASS +// Description: Section of OtpSecret class that holds JSON/XML serialization members and attributes +// Base file: OtpSecret.Base.cs + +[Serializable] +[JsonConverter(typeof(OtpSecretJsonConverter))] +public partial class OtpSecret +{ + /// + public XmlSchema? GetSchema() => null; + + /// + public void ReadXml(XmlReader reader) + { + reader.MoveToContent(); + + if (reader.NodeType != XmlNodeType.Element) + throw new XmlException("Invalid XML element."); + + byte[] secret = DefaultEncoder.GetBytes(reader.ReadElementContentAsString()); + +#pragma warning disable CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter + Unsafe.AsRef(this) = new OtpSecret(secret); +#pragma warning restore CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter + } + + /// + public void WriteXml(XmlWriter writer) + { + writer.WriteAttributeString("encoding", DefaultEncoder.Scheme); + writer.WriteAttributeString("length", _secret.Length.ToString()); + writer.WriteString(DefaultEncoder.EncodeBytes(_secret)); + } +} diff --git a/libraries/SimpleOTP/OtpSecret/OtpSecret.Static.cs b/libraries/SimpleOTP/OtpSecret/OtpSecret.Static.cs new file mode 100644 index 0000000..4f325bd --- /dev/null +++ b/libraries/SimpleOTP/OtpSecret/OtpSecret.Static.cs @@ -0,0 +1,132 @@ +using SimpleOTP.Encoding; + +namespace SimpleOTP; + +// THIS IS THE SECTION OF A PARTIAL CLASS +// Description: Section of OtpSecret class that holds static members +// Base file: OtpSecret.Base.cs + +public partial class OtpSecret +{ + /// + /// Gets or sets the default encoder for parsing/encoding/serializing secrets. + /// + public static IEncoder DefaultEncoder { get; set; } = Base32Encoder.Instance; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static implicit operator byte[](OtpSecret secret) => secret._secret; + public static implicit operator string(OtpSecret secret) => secret.ToString(); + + public static explicit operator OtpSecret(byte[] secret) => new(secret); + public static explicit operator OtpSecret(string secret) => new(secret); + + public static bool operator ==(OtpSecret left, OtpSecret right) => left.Equals(right); + public static bool operator ==(OtpSecret left, byte[] right) => left.Equals(right); + public static bool operator ==(OtpSecret left, string right) => left.Equals(right); + public static bool operator ==(byte[] left, OtpSecret right) => right.Equals(left); + public static bool operator ==(string left, OtpSecret right) => right.Equals(left); + + public static bool operator !=(OtpSecret left, OtpSecret right) => !(left == right); + public static bool operator !=(OtpSecret left, byte[] right) => !(left == right); + public static bool operator !=(OtpSecret left, string right) => !(left == right); + public static bool operator !=(byte[] left, OtpSecret right) => !(left == right); + public static bool operator !=(string left, OtpSecret right) => !(left == right); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Creates a copy of the specified object. + /// + /// The object to copy. + /// A copy of the specified object. + public static OtpSecret CreateCopy(OtpSecret source) + { + byte[] bytes = new byte[source._secret.Length]; + Array.Copy(source._secret, bytes, source._secret.Length); + return new(bytes); + } + + /// + /// Creates a new random object with a default length of 20 bytes. + /// + /// + /// 20 bytes (160 bits) is the recommended key length specified by RFC 4226. + /// Minimal recommended length is 16 bytes (128 bits). + /// + /// A new random object. + public static OtpSecret CreateNew() => + new(); + + /// + /// Creates a new random object with the specified length. + /// + /// + /// 20 bytes (160 bits) is the recommended key length specified by RFC 4226. + /// Minimal recommended length is 16 bytes (128 bits). + /// + /// The length of the secret in bytes. + /// A new random object. + /// is less than 1. + public static OtpSecret CreateNew(int length) => + new(length); + + /// + /// Parses a Base32-encoded string into an object. + /// + /// The Base32-encoded string. + /// An object. + /// is null. + /// is empty or contains invalid characters or only whitespace. + public static OtpSecret Parse(string secret) => + new(secret); + + /// + /// Parses a Base32-encoded string into an object. + /// + /// The Base32-encoded string. + /// The encoder. + /// An object. + /// is null. + /// is empty or contains invalid characters or only whitespace. + public static OtpSecret Parse(string secret, IEncoder encoder) => + new(secret, encoder); + + /// + /// Tries to parse a Base32-encoded string into an object. + /// + /// The Base32-encoded string. + /// When this method returns, contains the object, if the conversion succeeded, or default if the conversion failed. + /// true if was converted successfully; otherwise, false. + public static bool TryParse(string secret, out OtpSecret? otpSecret) => + TryParse(secret, DefaultEncoder, out otpSecret); + + /// + /// Tries to parse a Base32-encoded string into an object. + /// + /// The Base32-encoded string. + /// The encoder. + /// When this method returns, contains the object, if the conversion succeeded, or default if the conversion failed. + /// true if was converted successfully; otherwise, false. + public static bool TryParse(string secret, IEncoder encoder, out OtpSecret? otpSecret) + { + try + { + otpSecret = new(secret, encoder); + return true; + } + catch + { + otpSecret = null; + return false; + } + } + + /// + /// Creates a new object from a byte array. + /// + /// The byte array. + /// An object. + /// is null. + /// is empty. + public static OtpSecret FromBytes(byte[] secret) => + new(secret); +} diff --git a/libraries/SimpleOTP/OtpType.cs b/libraries/SimpleOTP/OtpType.cs new file mode 100644 index 0000000..eaaa894 --- /dev/null +++ b/libraries/SimpleOTP/OtpType.cs @@ -0,0 +1,22 @@ +namespace SimpleOTP; + +/// +/// Represents the type of One-Time Password (OTP). +/// +public enum OtpType +{ + /// + /// Time-based One-Time Password (TOTP). + /// + /// + /// RFC 6238 + /// + Totp = 0, + /// + /// HMAC-based One-Time Password (HOTP). + /// + /// + /// RFC 4226 + /// + Hotp = 1 +} diff --git a/libraries/SimpleOTP/OtpUriFormat.cs b/libraries/SimpleOTP/OtpUriFormat.cs new file mode 100644 index 0000000..8eb6459 --- /dev/null +++ b/libraries/SimpleOTP/OtpUriFormat.cs @@ -0,0 +1,67 @@ +namespace SimpleOTP; + +/// +/// Bitwise flags for specifying the format of One-Time Password (OTP) URIs. +/// +public enum OtpUriFormat : ushort +{ + /// + /// Represents a minimal URI format - only non-default properties are included. + /// + /// + /// This is the default format. + /// + Minimal = 0b_0000_0001, + + /// + /// Represents a full URI format - all properties are included. + /// + Full = 0b_0000_0010, + + /// + /// Represents a Google URI format. + /// + /// + /// This is the default format.
+ /// Google Authenticator. Key Uri Format + ///
+ Google = 0b_0001_0000, + + /// + /// Represents an Apple URI format. + /// + /// + /// + /// Apple. Securing Logins with iCloud Keychain Verification Codes + /// + /// + Apple = 0b_0010_0000, + + /// + /// Represents an IBM URI format. + /// + /// + /// + /// IBM. Authentication Configuring TOTP One-Time Password Mechanism + /// + /// + IBM = 0b_0100_0000, + + /// + /// Represents a Yubico URI format. + /// + /// + /// + /// Yubico. URI String Format + /// + /// + Yubico = 0b_1000_0000, + + /// + /// Represents an IIJ URI format. + /// + /// + /// Internet Initiative Japan. URI format + /// + IIJ = 0b_0001_0000_0000 +} diff --git a/libraries/SimpleOTP/SimpleOTP.csproj b/libraries/SimpleOTP/SimpleOTP.csproj new file mode 100644 index 0000000..3117882 --- /dev/null +++ b/libraries/SimpleOTP/SimpleOTP.csproj @@ -0,0 +1,53 @@ + + + + net8.0 + enable + enable + true + + + + true + snupkg + + + + EugeneFox.SimpleOTP + 8.0.0.0-rc1 + Eugene Fox + Copyright © Eugene Fox 2024 + en-US + MIT + + + + icon.png + README.md + git + https://github.com/XFox111/SimpleOTP.git + https://github.com/XFox111/SimpleOTP + + + + otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth + + Feature-rich, fast, and customizable library for implementation TOTP/HOTP authenticators and validators. + + + (BREAKING CHANGE) Complete overhaul of the library. See https://github.com/XFox111/SimpleOTP/releases/tag/2.0.0 for more details. + + + + + + True + + + + True + + + + + diff --git a/libraries/SimpleOTP/ToleranceSpan.cs b/libraries/SimpleOTP/ToleranceSpan.cs new file mode 100644 index 0000000..7b27f1a --- /dev/null +++ b/libraries/SimpleOTP/ToleranceSpan.cs @@ -0,0 +1,59 @@ +namespace SimpleOTP; + +/// +/// Represents a span of tolerance values used in OTP (One-Time Password) validation. +/// +/// The number of periods/counter values behind the current value. +/// The number of periods/counter values ahead of the current value. +public readonly struct ToleranceSpan(int behind, int ahead) : IEquatable +{ + /// + /// Gets the default recommended value. + /// + /// The default value: 1 counter/period ahead and behind. + public static ToleranceSpan Default { get; } = new(1); + + /// + /// Gets the number of tolerance values behind the current value. + /// + public int Behind { get; init; } = behind; + + /// + /// Gets the number of tolerance values ahead of the current value. + /// + public int Ahead { get; init; } = ahead; + + /// + /// Initializes a new instance of the struct with the specified tolerance value. + /// The and properties will be set to the same value. + /// + /// The tolerance value to set for both and . + public ToleranceSpan(int tolerance) : this(tolerance, tolerance) { } + + /// + public bool Equals(ToleranceSpan other) => + Behind == other.Behind && Ahead == other.Ahead; + + /// + public override bool Equals(object? obj) => + obj is ToleranceSpan span && Equals(span); + + /// + public override int GetHashCode() => + HashCode.Combine(Behind, Ahead); + + /// + /// Returns the string representation of the struct. + /// + /// The string representation of the struct. + public override string ToString() => + $"(-{Behind}, +{Ahead})"; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public static implicit operator ToleranceSpan(int tolerance) => new(tolerance); + public static implicit operator ToleranceSpan((int behind, int ahead) tolerance) => new(tolerance.behind, tolerance.ahead); + + public static bool operator ==(ToleranceSpan left, ToleranceSpan right) => left.Equals(right); + public static bool operator !=(ToleranceSpan left, ToleranceSpan right) => !(left == right); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member +} diff --git a/libraries/SimpleOTP/Totp.cs b/libraries/SimpleOTP/Totp.cs new file mode 100644 index 0000000..12b5b94 --- /dev/null +++ b/libraries/SimpleOTP/Totp.cs @@ -0,0 +1,98 @@ +namespace SimpleOTP; + +/// +/// Represents a Time-based One-Time Password (TOTP) generator. +/// +public class Totp : Otp +{ + /// + /// Gets or sets the time period (in seconds) for which each generated OTP is valid. + /// + /// Also used to calculate the current counter value. + public int Period { get; set; } = 30; + + #region Constructors + + /// + /// Initializes a new instance of the class with the specified secret key. + /// + /// The secret key used for generating OTP codes. + public Totp(OtpSecret secret) : base(secret) { } + + /// + /// Initializes a new instance of the class with the specified secret key and number of OTP code digits. + /// + /// The secret key used for generating OTP codes. + /// The time period (in seconds) for which each generated OTP is valid. + public Totp(OtpSecret secret, int period) : base(secret) => + Period = period; + + /// + /// Initializes a new instance of the class with the specified secret key, number of OTP code digits, and time period. + /// + /// The secret key used for generating OTP codes. + /// The time period (in seconds) for which each generated OTP is valid. + /// The number of digits in the OTP code. + public Totp(OtpSecret secret, int period, int digits) : base(secret, digits) => + Period = period; + + /// + /// Initializes a new instance of the class with the specified secret key, hash algorithm, and time period. + /// + /// The secret key used for generating OTP codes. + /// The time period (in seconds) for which each generated OTP is valid. + /// The algorithm used for generating OTP codes. + public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm) : base(secret, algorithm) => + Period = period; + + /// + /// Initializes a new instance of the class with the specified secret key, hash algorithm, number of digits, and time period. + /// + /// The secret key used for generating OTP codes. + /// The time period (in seconds) for which each generated OTP is valid. + /// The algorithm used for generating OTP codes. + /// The number of digits in the OTP code. + public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) => + Period = period; + + #endregion + + /// + /// Generates an OTP based on the specified counter value. + /// + /// The counter value to use for OTP generation. + /// An instance of representing the generated OTP. + public override OtpCode Generate(long counter) => + new(Compute(counter), Digits, DateTime.UnixEpoch.AddSeconds((counter + 1) * Period)); + + /// + /// Generates an OTP based on the specified date and time. + /// + /// The date and time to use for OTP generation. + /// An instance of representing the generated OTP. + public OtpCode Generate(DateTimeOffset date) => + Generate(date.ToUnixTimeSeconds() / Period); + + + /// + /// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value. + /// + /// The OTP code to validate. + /// The tolerance span for code validation. + /// The base timestamp value. + /// The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value. + /// true if the OTP code is valid; otherwise, false. + /// + /// Implementation for the algorithm was not found. + /// Use to register an implementation. + /// + public bool Validate(OtpCode code, ToleranceSpan tolerance, DateTimeOffset baseTime, out int resyncValue) => + Validate(code, tolerance, baseTime.ToUnixTimeSeconds() / 30, out resyncValue); + + /// + /// Gets the current counter value based on the current UTC time and the configured time period. + /// + /// The current counter value. + protected override long GetCounter() => + DateTimeOffset.UtcNow.ToUnixTimeSeconds() / Period; +}