1
0
mirror of https://github.com/XFox111/SimpleOTP.git synced 2026-04-22 08:00:45 +03:00

Major 2.0 (#20)

* New 2.0 version + DependencyInjection library

* Updated docs and repo settings (devcontainers, vscode, github, etc.)

* Added tests

* Fixed bugs

* Minor test project refactoring

* Updated projects
- Added symbol packages
- Updated package versions to pre-release

* Updated SECURITY.md

* Added GitHub Actions workflows
This commit is contained in:
2024-09-26 03:20:30 +03:00
committed by GitHub
parent 42f968171b
commit 1b989e7b35
87 changed files with 4076 additions and 2532 deletions
+39
View File
@@ -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"
}
-33
View File
@@ -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.
+107
View File
@@ -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
+7
View File
@@ -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.
-20
View File
@@ -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.
@@ -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
+42
View File
@@ -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
+9 -8
View File
@@ -1,10 +1,11 @@
Fixes: ## Description
<!--Put short description of the pull request here-->
## Changelog ### Changelog
- Item 1 <!-- - e.g. Updated `PackageVersion` from `1.0.0` to `1.1.0` -->
- Item 2
- Item 3
## PR Checklist Resolves: #issue_number
- [ ] Update package version and release notes
- [ ] Tests and documentation <!-- IMPORTANT -->
<!-- Before submitting a pull request, make sure, that there's an issue related to it, and that you are assigned. -->
<!-- Visit https://github.com/xfox111/SimpleOTP/blob/main/CONTRIBUTING.md for more information. -->
+51 -8
View File
@@ -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" name: "CodeQL"
on: on:
push: push:
branches: [ master ] branches: [ "main" ]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.github/*'
- '!.github/workflows/codeql-analysis.yml'
- '.vscode/*'
- '.devcontainer/*'
- '.assets/*'
pull_request: 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: jobs:
analyze: analyze:
@@ -19,22 +44,40 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'csharp' ] language: [ 'csharp' ]
# Learn more: # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# 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 # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v1 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} 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). # 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 - 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 - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1 uses: github/codeql-action/analyze@v3
+40
View File
@@ -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
+52
View File
@@ -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
+136 -2
View File
@@ -1,7 +1,10 @@
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
## ##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files # User-specific files
*.rsuser *.rsuser
@@ -23,6 +26,7 @@ mono_crash.*
[Rr]eleases/ [Rr]eleases/
x64/ x64/
x86/ x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/ [Aa][Rr][Mm]/
[Aa][Rr][Mm]64/ [Aa][Rr][Mm]64/
bld/ bld/
@@ -56,11 +60,17 @@ dlldata.c
# Benchmark Results # Benchmark Results
BenchmarkDotNet.Artifacts/ BenchmarkDotNet.Artifacts/
# .NET Core # .NET
project.lock.json project.lock.json
project.fragment.lock.json project.fragment.lock.json
artifacts/ artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop # StyleCop
StyleCopReport.xml StyleCopReport.xml
@@ -86,6 +96,7 @@ StyleCopReport.xml
*.tmp_proj *.tmp_proj
*_wpftmp.csproj *_wpftmp.csproj
*.log *.log
*.tlog
*.vspscc *.vspscc
*.vssscc *.vssscc
.builds .builds
@@ -137,6 +148,11 @@ _TeamCity*
.axoCover/* .axoCover/*
!.axoCover/settings.json !.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results # Visual Studio code coverage results
*.coverage *.coverage
*.coveragexml *.coveragexml
@@ -284,6 +300,17 @@ node_modules/
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw *.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 # Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts **/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts
@@ -340,6 +367,9 @@ ASALocalRun/
# Local History for Visual Studio # Local History for Visual Studio
.localhistory/ .localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database # BeatPulse healthcheck temp database
healthchecksdb healthchecksdb
@@ -348,3 +378,107 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder # Ionide (cross platform F# VS Code tools) working folder
.ionide/ .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
+11
View File
@@ -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"
]
}
+13
View File
@@ -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,
}
+80
View File
@@ -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"
}
]
}
-12
View File
@@ -1,12 +0,0 @@
{
"scanSettings": {
"baseBranches": []
},
"checkRunSettings": {
"vulnerableCheckRunConclusionLevel": "failure",
"displayMode": "diff"
},
"issueSettings": {
"minSeverityLevel": "LOW"
}
}
+105 -47
View File
@@ -2,75 +2,133 @@
## Our Pledge ## Our Pledge
In the interest of fostering an open and welcoming environment, we as We as members, contributors, and leaders pledge to make participation in our
contributors and maintainers pledge to making participation in our project and community a harassment-free experience for everyone, regardless of age, body
our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender
size, disability, ethnicity, sex characteristics, gender identity and expression, identity and expression, level of experience, education, socio-economic status,
level of experience, education, socio-economic status, nationality, personal nationality, personal appearance, race, caste, color, religion, or sexual
appearance, race, religion, or sexual identity and orientation. identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards ## Our Standards
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to a positive environment for our
include: community include:
* Using welcoming and inclusive language * Demonstrating empathy and kindness toward other people
* Being respectful of differing viewpoints and experiences * Being respectful of differing opinions, viewpoints, and experiences
* Gracefully accepting constructive criticism * Giving and gracefully accepting constructive feedback
* Focusing on what is best for the community * Accepting responsibility and apologizing to those affected by our mistakes,
* Showing empathy towards other community members 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 * The use of sexualized language or imagery, and sexual attention or advances of
advances any kind
* Trolling, insulting/derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment * Public or private harassment
* Publishing others' private information, such as a physical or electronic * Publishing others' private information, such as a physical or email address,
address, without explicit permission without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a * 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 Community leaders are responsible for clarifying and enforcing our standards of
behavior and are expected to take appropriate and fair corrective action in acceptable behavior and will take appropriate and fair corrective action in
response to any instances of unacceptable behavior. response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Project maintainers have the right and responsibility to remove, edit, or Community leaders have the right and responsibility to remove, edit, or reject
reject comments, commits, code, wiki edits, issues, and other contributions comments, commits, code, wiki edits, issues, and other contributions that are
that are not aligned to this Code of Conduct, or to ban temporarily or not aligned to this Code of Conduct, and will communicate reasons for moderation
permanently any contributor for other behaviors that they deem inappropriate, decisions when appropriate.
threatening, offensive, or harmful.
## Scope ## Scope
This Code of Conduct applies both within project spaces and in public spaces This Code of Conduct applies within all community spaces, and also applies when
when an individual is representing the project or its community. Examples of an individual is officially representing the community in public spaces.
representing a project or community include using an official project e-mail Examples of representing our community include using an official email address,
address, posting via an official social media account, or acting as an appointed 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 representative at an online or offline event.
further defined and clarified by project maintainers.
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at opensource@xfox111.net. All reported to the community leaders responsible for enforcement at
complaints will be reviewed and investigated and will result in a response that [opensource@xfox111.net](mailto:opensource@xfox111.net).
is deemed necessary and appropriate to the circumstances. The project team is All complaints will be reviewed and investigated promptly and fairly.
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good All community leaders are obligated to respect the privacy and security of the
faith may face temporary or permanent repercussions as determined by other reporter of any incident.
members of the project's leadership.
## 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 ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, This Code of Conduct is adapted from the [Contributor Covenant][homepage],
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 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 [homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
For answers to common questions about this code of conduct, see [Mozilla CoC]: https://github.com/mozilla/diversity
https://www.contributor-covenant.org/faq [FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+1 -242
View File
@@ -1,243 +1,2 @@
# Contribution Guidelines # Contribution Guidelines
Welcome, and thank you for your interest in contributing to my project! This article has been moved to the [project's Wiki section](https://github.com/XFox111/SimpleOTP/wiki/Contribution-Guidelines)
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)
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+41 -85
View File
@@ -1,87 +1,42 @@
# SimpleOTP [![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)](https://github.com/xfox111/SimpleOTP/commits/master) [![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/SimpleOTP?label=Last+update)](https://github.com/XFox111/SimpleOTP/commits/main)
[![MIT License](https://img.shields.io/github/license/xfox111/SimpleOTP)](https://opensource.org/licenses/MIT)
[![Twitter Follow](https://img.shields.io/twitter/follow/xfox111?style=social)](https://twitter.com/xfox111) ![SimpleOTP](https://cdn.xfox111.net/projects/simple-otp/SimpleOTP.svg)
[![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)
.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 ## Features
- Generate and validate OTP codes - Full support for Time-based OTP generation and validation ([RFC 6238][RFC-6238])
- 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 - Full support for HMAC-based OTP generation and validation ([RFC 4226][RFC-4226])
- Support of HMAC-SHA1, HMAC-SHA256 and HMAC-SHA512 hashing algorithms - 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
- Setup URI parser - Built-in `otpauth:` URI formatters to comply with different specifications (Apple, Google, IBM, and more)
- Database-ready configuration models - Fluent API support
- Configuration generator for server-side implementation - Supplementary `DependencyInjection` package for easier implementation in ASP.NET
- QR code generator - Continuous support of current and upcoming .NET versions
- No dependencies - And more!
![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();
```
## Download ## 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 | Package | Info | Download |
[![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) | `EugeneFox.SimpleOTP` | [![Nuget](https://img.shields.io/nuget/v/EugeneFox.SimpleOTP)][nuget]<br />[![Nuget](https://img.shields.io/nuget/dt/EugeneFox.SimpleOTP)][nuget] | [NuGet Gallery][nuget]<br />[GitHub Releases](https://github.com/xfox111/SimpleOTP/releases/latest) |
| `EugeneFox.SimpleOTP.DependencyInjection` | [![Nuget](https://img.shields.io/nuget/v/EugeneFox.SimpleOTP.DependencyInjection)][nuget-di]<br />[![Nuget](https://img.shields.io/nuget/dt/EugeneFox.SimpleOTP.DependencyInjection)][nuget-di] | [NuGet Gallery][nuget-di]<br />[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) Use these commands to install SimpleOTP package in your project:
![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/xfox111/GitHub%2520CI/13?label=Code+coverage) ```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 ## Contributing
[![GitHub issues](https://img.shields.io/github/issues/xfox111/SimpleOTP)](https://github.com/xfox111/SimpleOTP/issues) [![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) [![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: 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 [source code changes](https://github.com/xfox111/SimpleOTP/pulls)
- Review documentation and make pull requests for anything from typos to new content - 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: 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)
- [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)
## 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 [![Twitter Follow](https://img.shields.io/twitter/follow/xfox111?style=social)](https://twitter.com/xfox111)
> ©2021 Eugene Fox [![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
+13 -8
View File
@@ -1,15 +1,20 @@
# Security Policy # Security Policy
## Supported Versions ## 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 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.
After release of new version of framework previous comes out of support in 3 months
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 ## 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 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
-22
View File
@@ -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")]
-52
View File
@@ -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
{
/// <summary>
/// Unit-tests for Base32 encoder.
/// </summary>
[TestClass]
public class Base32UnitTest
{
/// <summary>
/// Test encoder with byte array.
/// </summary>
[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]);
}
/// <summary>
/// Test encoder with string content.
/// </summary>
[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);
}
}
}
@@ -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
{
/// <summary>
/// Unit-tests for key generator.
/// </summary>
[TestClass]
public class SecretGeneratorUnitTest
{
/// <summary>
/// Overall test of key generator.
/// </summary>
[TestMethod("Overall generator tests")]
public void Test_Generator()
{
Assert.ThrowsException<ArgumentOutOfRangeException>(() => SecretGenerator.GenerateSecret(64));
Assert.ThrowsException<ArgumentOutOfRangeException>(() => SecretGenerator.GenerateSecret(256));
string key = SecretGenerator.GenerateSecret();
Assert.IsFalse(string.IsNullOrWhiteSpace(key));
key = SecretGenerator.GenerateSecret(128);
Assert.IsFalse(string.IsNullOrWhiteSpace(key));
}
}
}
-160
View File
@@ -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
{
/// <summary>
/// Unit-tests for OTP URI parser.
/// </summary>
[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,
},
};
/// <summary>
/// Test parser with full TOTP URI.
/// </summary>
[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);
}
/// <summary>
/// Test parser with full HOTP URI.
/// </summary>
[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);
}
/// <summary>
/// Test parser with TOTP URI. Minimal parameter set.
/// </summary>
[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);
}
/// <summary>
/// Test parser with HOTP URI. Minimal parameter set.
/// </summary>
[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);
}
/// <summary>
/// Test parser with invalid OTP URIs.
/// </summary>
[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<ArgumentException>(() => SimpleOTP.Helpers.UriParser.ParseUri(new Uri(uri)));
}
}
}
-37
View File
@@ -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
{
/// <summary>
/// Unit-tests for OTP code model.
/// </summary>
[TestClass]
public class OTPCodeUnitTest
{
/// <summary>
/// Get formatted OTP code.
/// </summary>
[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"));
}
}
}
@@ -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
{
/// <summary>
/// Unit-tests for OTP generator configuration model.
/// </summary>
[TestClass]
public class OTPConfigurationUnitTest
{
/// <summary>
/// Get otpauth link from minimal configuration.
/// </summary>
[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);
}
/// <summary>
/// Get otpauth link from complete configurations (TOTP + HOTP).
/// </summary>
[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);
}
/// <summary>
/// Test user-friendly secret formatting.
/// </summary>
[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());
}
/// <summary>
/// Test QR code generation for OTP config.
/// </summary>
/// <returns><see cref="Task"/>.</returns>
[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);
}
}
}
-43
View File
@@ -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
{
/// <summary>
/// OTP factory class unit-tests.
/// </summary>
[TestClass]
public class OTPFactoryUnitTest
{
/// <summary>
/// Complex test of OTP factory.
/// </summary>
/// <returns><see cref="Task"/>.</returns>
[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);
}
}
}
-139
View File
@@ -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
{
/// <summary>
/// Unit-tests for OTP generator.
/// </summary>
[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"));
/// <summary>
/// Test time-based OTP generator with pre-calculated code.
/// </summary>
[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);
}
/// <summary>
/// Test time-based OTP generator with customly formatted secret.
/// </summary>
[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.");
}
/// <summary>
/// Test HOTP generator with pre-calculated code.
/// </summary>
[TestMethod("HOTP code generation")]
public void GenerateCode_Hotp()
{
var code = OTPService.GenerateCode(ref hotpConfig);
Assert.AreEqual(457608, code.Code);
Assert.AreEqual(10001, hotpConfig.Counter);
}
/// <summary>
/// Test time-based OTP validator with series of codes.
/// </summary>
[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<ArgumentException>(() => OTPService.ValidateTotp(0, hotpConfig));
}
/// <summary>
/// Test HOTP validator with series of codes.
/// </summary>
[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<ArgumentException>(() => OTPService.ValidateHotp(0, ref totpConfig, 1, true));
}
}
}
-45
View File
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<AssemblyName>SimpleOTP.Tests</AssemblyName>
<PackageId>SimpleOTP.Tests</PackageId>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
</PropertyGroup>
<ItemGroup>
<None Remove="stylecop.json" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="stylecop.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.5" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.5" />
<PackageReference Include="coverlet.collector" Version="3.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.66">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleOTP\SimpleOTP.csproj" />
</ItemGroup>
</Project>
-48
View File
@@ -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
}
}
}
+66
View File
@@ -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));
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\libraries\SimpleOTP\SimpleOTP.csproj" />
<ProjectReference Include="..\libraries\SimpleOTP.DependencyInjection\SimpleOTP.DependencyInjection.csproj" />
</ItemGroup>
</Project>
+63
View File
@@ -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));
}
}
+25 -36
View File
@@ -1,51 +1,40 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16 # Visual Studio Version 17
VisualStudioVersion = 16.6.30114.105 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleOTP", "SimpleOTP\SimpleOTP.csproj", "{518EF6D5-DB32-4406-B289-6E11DB6E1D67}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "libraries", "libraries", "{3C07FF44-31AD-4E2B-A651-89890708590A}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleOTP.Test", "SimpleOTP.Test\SimpleOTP.Test.csproj", "{54AC322C-6119-456B-BFE6-CCF3CC504C56}" 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
SolutionGuid = {247D25FB-623A-4CF6-ABD4-06B4CBF0AD3E} {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 EndGlobalSection
EndGlobal EndGlobal
-33
View File
@@ -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
{
/// <summary>
/// Available OTP encryption algorithms.
/// </summary>
public enum Algorithm
{
/// <summary>
/// HMAC-SHA1 hasing algorithm (default)<br/>
/// <a href="https://datatracker.ietf.org/doc/html/rfc3174">RFC 3174</a>
/// </summary>
SHA1 = 0,
/// <summary>
/// HMAC-SHA256 hasing algorithm<br/>
/// <a href="https://datatracker.ietf.org/doc/html/rfc4634">RFC 4634</a>
/// </summary>
SHA256 = 1,
/// <summary>
/// HMAC-SHA512 hasing algorithm<br/>
/// <a href="https://datatracker.ietf.org/doc/html/rfc4634">RFC 4634</a>
/// </summary>
SHA512 = 2
}
}
-27
View File
@@ -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
{
/// <summary>
/// OTP algorithm types.
/// </summary>
public enum OTPType
{
/// <summary>
/// Time-based One-Time Password<br/>
/// <a href="https://datatracker.ietf.org/doc/html/rfc6238">RFC 6238</a>
/// </summary>
TOTP = 0,
/// <summary>
/// HMAC-based One-Time Password<br/>
/// /// <a href="https://datatracker.ietf.org/doc/html/rfc4226">RFC 4226</a>
/// </summary>
HOTP = 1
}
}
-23
View File
@@ -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")]
-63
View File
@@ -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
{
/// <summary>
/// Helper class which contains methods for encoding and decoding Base32 bytes.
/// </summary>
internal static class Base32Encoder
{
// Standard RFC 4648 Base32 alphabet
private const string AllowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/// <summary>
/// Encode byte array to Base32 string.
/// </summary>
/// <param name="data">Byte array to encode.</param>
/// <returns>Base32 string.</returns>
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;
}
/// <summary>
/// Decode Base32 string into byte array.
/// </summary>
/// <param name="base32str">Base32-encoded string.</param>
/// <returns>Initial byte array.</returns>
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;
}
}
}
-36
View File
@@ -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
{
/// <summary>
/// Helper class for OTP secret generation.
/// </summary>
public static class SecretGenerator
{
/// <summary>
/// Generate OTP secret key.
/// </summary>
/// <param name="length">Length of the key in bits<br/>
/// It should belong to [128-160] bit span<br/>
/// Default is: 160 bits.</param>
/// <remarks>Number of bits will be rounded down to the nearest number which divides by 8.</remarks>
/// <returns>Base32 encoded alphanumeric string with length form 16 to 20 characters.</returns>
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);
}
}
}
-72
View File
@@ -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
{
/// <summary>
/// Helper class which contains methods to parse OTP auth URIs.
/// </summary>
internal static class UriParser
{
/// <summary>
/// Parses OTP Auth URI and returns configuration object for further processing.
/// URI should be correctly formed.
/// </summary>
/// <remarks>
/// For more information please refer to <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Key Uri Format</a>.
/// </remarks>
/// <param name="uri">OTP Auth URI. Should be correctly formed.
/// For more information please refer to <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Key Uri Format</a>.</param>
/// <returns><see cref="OTPConfiguration"/> configuration object, which contains data for OTP generation.</returns>
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;
}
}
}
-51
View File
@@ -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
{
/// <summary>
/// OTP code object model.
/// </summary>
public record OTPCode
{
/// <summary>
/// Gets or sets OTP code.
/// </summary>
public int Code { get; set; }
/// <summary>
/// Gets or sets date-time until the code is valid.
/// </summary>
public DateTime? Expiring { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="OTPCode"/> class.
/// </summary>
public OTPCode()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="OTPCode"/> class.<br/>
/// Use this constructor only for HOTP key. Otherwise, fill out all properties.
/// </summary>
/// <param name="code">OTP code.</param>
public OTPCode(int code) =>
Code = code;
/// <summary>
/// Gets valid 6 digit or more OTP code.
/// </summary>
/// <param name="formatter">String formatter. Other variation:
/// <code>"000 000"</code></param>
/// <returns>Formatted OTP code string with 6 or more digits.</returns>
public string GetCode(string formatter = "000000") =>
Code.ToString(formatter);
}
}
-228
View File
@@ -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
{
/// <summary>
/// OTP generator configuration object.
/// </summary>
public record OTPConfiguration
{
/// <summary>
/// Gets or sets unique identifier of current configuration instance.
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Gets or sets OTP algorithm type.
/// </summary>
public OTPType Type { get; set; } = OTPType.TOTP;
/// <summary>
/// Gets or sets name of config issuer/service.
/// </summary>
public string IssuerLabel { get; set; }
/// <summary>
/// Gets or sets username or email of current config.
/// </summary>
public string AccountName { get; set; }
/// <summary>
/// Gets or sets secret key for OTP code generation.
/// </summary>
public string Secret { get; set; }
/// <summary>
/// Gets or sets internal issuer name for additional identification. Currently should be the same with <see cref="IssuerLabel"/>.
/// </summary>
public string Issuer { get; set; }
/// <summary>
/// Gets or sets oTP hashing algorithm.
/// </summary>
public Algorithm Algorithm { get; set; } = Algorithm.SHA1;
/// <summary>
/// Gets or sets number of digits of OTP code.
/// </summary>
public int Digits { get; set; } = 6;
/// <summary>
/// Gets or sets counter for HOTP generation. Update each time password has been generated.<br/>
/// HOTP only.
/// </summary>
public long Counter { get; set; } = 0;
/// <summary>
/// Gets or sets time of OTP validity interval. Used to calculate TOTP counter.
/// TOTP only.
/// </summary>
public TimeSpan Period { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Generate a new OTP configuration to send it to client.
/// </summary>
/// <remarks>
/// <list type="table">
/// <listheader>Algorithm parameters:</listheader>
/// <item>
/// <term>OTP algorithm</term>
/// <description>Time-based OTP</description>
/// </item>
/// <item>
/// <term>Key length</term>
/// <description>160 bit (20 characters)</description>
/// </item>
/// <item>
/// <term>Hashing algorithm</term>
/// <description>HMAC-SHA-1</description>
/// </item>
/// <item>
/// <term>Number of digits</term>
/// <description>6</description>
/// </item>
/// <item>
/// <term>Period</term>
/// <description>30 seconds</description>
/// </item>
/// </list>
/// </remarks>
/// <param name="issuer">Name of your application/service.</param>
/// <param name="accountName">Username/email of the user.</param>
/// <returns>Valid <see cref="OTPConfiguration"/> configuraion.</returns>
public static OTPConfiguration GenerateConfiguration(string issuer, string accountName) =>
new ()
{
Issuer = issuer,
IssuerLabel = issuer,
AccountName = accountName,
Secret = SecretGenerator.GenerateSecret()
};
/// <summary>
/// Load OTP configuraiton with default parameters.
/// </summary>
/// <remarks>
/// <list type="table">
/// <listheader>Algorithm parameters:</listheader>
/// <item>
/// <term>OTP algorithm</term>
/// <description>Time-based OTP</description>
/// </item>
/// <item>
/// <term>Hashing algorithm</term>
/// <description>HMAC-SHA-1</description>
/// </item>
/// <item>
/// <term>Number of digits</term>
/// <description>6</description>
/// </item>
/// <item>
/// <term>Period</term>
/// <description>30 seconds</description>
/// </item>
/// </list>
/// </remarks>
/// <param name="secret">OTP generator secret key (Base32 encoded string).</param>
/// <param name="issuer">Name of your application/service.</param>
/// <param name="accountName">Username/email of the user.</param>
/// <returns>Valid <see cref="OTPConfiguration"/> configuraion.</returns>
public static OTPConfiguration GetConfiguration(string secret, string issuer, string accountName) =>
new ()
{
Issuer = issuer,
IssuerLabel = issuer,
AccountName = accountName,
Secret = secret
};
/// <summary>
/// Loads OTP configuration from OTP AUTH URI.
/// </summary>
/// <remarks>
/// For more information please refer to <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Key Uri Format</a>.
/// </remarks>
/// <param name="uri">OTP Auth URI. Should be correctly formed.</param>
/// <returns>Valid <see cref="OTPConfiguration"/> configuraion.</returns>
public static OTPConfiguration GetConfiguration(Uri uri) =>
Helpers.UriParser.ParseUri(uri);
/// <summary>
/// Loads OTP configuration from OTP AUTH URI.
/// </summary>
/// <remarks>
/// For more information please refer to <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Key Uri Format</a>.
/// </remarks>
/// <param name="uri">OTP Auth URI. Should be correctly formed.</param>
/// <returns>Valid <see cref="OTPConfiguration"/> configuraion.</returns>
public static OTPConfiguration GetConfiguration(string uri) =>
GetConfiguration(new Uri(uri));
/// <summary>
/// Gets URI from current configuration to reuse it somewhere else.
/// </summary>
/// <returns>Valid OTP AUTH URI.</returns>
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);
}
/// <summary>
/// Returns secret key separated with whitespaces on groups of 4.
/// </summary>
/// <returns>Formatted secret key string.</returns>
public string GetFancySecret()
{
string secret = Secret;
for (int k = 0; 4 + (5 * k) < secret.Length; k++)
secret = secret.Insert(4 + (5 * k), " ");
return secret;
}
/// <summary>
/// Generates QR code image for current configuration with Google Chart API.
/// </summary>
/// <param name="qrCodeSize">QR code image size in pixels.</param>
/// <param name="requestTimeout">Web request timeout in seconds.</param>
/// <returns>string-encoded PNG image.</returns>
public async Task<string> 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;
}
}
}
-115
View File
@@ -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
{
/// <summary>
/// Represents method that will be called when new OTP code is generated.
/// </summary>
/// <param name="code">New OTP code instance.</param>
public delegate void OTPCodeUpdatedEventHandler(OTPCode code);
/// <summary>
/// Class used to streamline OTP code generation on client devices.
/// </summary>
/// <remarks>
/// <code>
/// var factory = new (config);<br/>
/// factory.CodeUpdated += (newCode) => Console.WriteLine(newCode.Code);
/// </code>
/// </remarks>
public class OTPFactory : INotifyPropertyChanged, IDisposable
{
private readonly Timer _timer = new (1000);
private OTPCode _currentCode;
/// <summary>
/// Gets or sets current valid OTP code instance.
/// </summary>
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;
/// <summary>
/// Gets or sets OTP configuration of the current instance.
/// </summary>
public OTPConfiguration Configuration
{
get => _configuration;
set
{
if (_configuration == value)
return;
_configuration = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Configuration)));
}
}
/// <summary>
/// Gets time left before current OTP code expires.
/// </summary>
public TimeSpan? TimeLeft => CurrentCode?.Expiring - DateTime.UtcNow;
/// <summary>
/// Event is fired when new OTP code is generated.
/// </summary>
public event OTPCodeUpdatedEventHandler CodeUpdated;
/// <inheritdoc/>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Initializes a new instance of the <see cref="OTPFactory"/> class.
/// </summary>
/// <param name="configuration">OTP configuration for codes producing.</param>
/// <param name="timerUpdateInterval">Interval for timer updates in milliseconds.</param>
public OTPFactory(OTPConfiguration configuration, int timerUpdateInterval = 1000)
{
Configuration = configuration;
CurrentCode = OTPService.GenerateCode(ref configuration);
_timer.Interval = timerUpdateInterval;
_timer.Elapsed += TimerElapsed;
_timer.Start();
}
/// <inheritdoc/>
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)));
}
}
}
-154
View File
@@ -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
{
/// <summary>
/// Service class for generating and validating OTP codes.
/// </summary>
public static class OTPService
{
/// <summary>
/// Generates a new OTP code with provided configuration.
/// </summary>
/// <remarks>
/// If you're using HOTP algorithm, save <paramref name="target"/> after calling the function.
/// </remarks>
/// <param name="target">OTP configuration object.</param>
/// <returns><see cref="OTPCode"/> instance with generated code.<br/>
/// If OTP algorithm is HOTP, <paramref name="target"/> counter is increased by 1.
/// </returns>
public static OTPCode GenerateCode(ref OTPConfiguration target) =>
GenerateCode(ref target, DateTime.UtcNow);
/// <summary>
/// Generates a new TOTP code with provided configuration and for specific interval.
/// </summary>
/// <remarks>
/// If you're using HOTP algorithm, save <paramref name="target"/> after calling the function.<br/>
/// If you're using HOTP algorithm, <paramref name="date"/> will be ignored.
/// </remarks>
/// <param name="target">OTP configuration object.</param>
/// <param name="date"><see cref="DateTime"/> for which the OTP should be generated.</param>
/// <returns><see cref="OTPCode"/> instance with generated code.<br/>
/// If OTP algorithm is HOTP, <paramref name="target"/> counter is increased by 1.
/// </returns>
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;
}
/// <summary>
/// Validates provided HOTP code with provided parameters.
/// </summary>
/// <remarks>
/// Use this method only with HOTP codes.
/// </remarks>
/// <param name="otp">HOTP code to validate.</param>
/// <param name="target">OTP configuration for check codes generation.</param>
/// <param name="toleranceSpan">Counter span from which OTP codes remain valid.</param>
/// <param name="resyncCounter">Defines whether method should resync <see cref="OTPConfiguration.Counter"/> of the <paramref name="target"/> or not after successful validation.</param>
/// <returns><c>True</c> if code is valid, <c>False</c> if it isn't.</returns>
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;
}
/// <summary>
/// Validates provided TOTP code with provided parameters.
/// </summary>
/// <remarks>
/// Use this method only with Time-based OTP codes.
/// </remarks>
/// <param name="otp">OTP code to validate.</param>
/// <param name="target">OTP configuration for check codes generation.</param>
/// <param name="toleranceTime">Time span from which OTP codes remain valid.<br/>
/// Default: 15 seconds.</param>
/// <returns><c>True</c> if code is valid, <c>False</c> if it isn't.</returns>
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<int> 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);
}
/// <inheritdoc cref="ValidateHotp(int, ref OTPConfiguration, int, bool)"/>
[Obsolete("Use ValidateHotp() instead.")]
public static bool ValidateCode(int otp, ref OTPConfiguration target, int toleranceSpan, bool resyncCounter) =>
ValidateHotp(otp, ref target, toleranceSpan, resyncCounter);
/// <inheritdoc cref="ValidateTotp(int, OTPConfiguration, TimeSpan?)"/>
[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;
}
}
-18
View File
@@ -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")]
-55
View File
@@ -1,55 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net5.0;netstandard2.1;netcoreapp3.1</TargetFrameworks>
<LangVersion>9</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<PropertyGroup>
<PackageId>SimpleOTP</PackageId>
<AssemblyName>SimpleOTP</AssemblyName>
<Version>1.2.3</Version>
<Description>.NET library for TOTP/HOTP implementation on server (ASP.NET) or client (Xamarin) side</Description>
<Authors>Eugene Fox</Authors>
<Company>FoxDev Studio</Company>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<Copyright>©2021 Eugene Fox</Copyright>
<RepositoryType>Git</RepositoryType>
<RepositoryUrl>https://github.com/XFox111/SimpleOTP</RepositoryUrl>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageTags>otp;totp;dotnet;hotp;authenticator;2fa;mfa;security;oath</PackageTags>
<PackageReleaseNotes>- Fixed invalid code generation with secrets which are lowercase or space-separated</PackageReleaseNotes>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<NoWarn>1701;1702;AD0001</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<NoWarn>1701;1702;AD0001</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Remove="stylecop.json" />
<None Include="..\LICENSE">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="stylecop.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.66">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
-48
View File
@@ -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
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-70
View File
@@ -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'
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SimpleOTP.Tests")]
@@ -0,0 +1,33 @@
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides methods for generating and validating One-Time Passwords.
/// </summary>
public interface IOtpService
{
/// <summary>
/// Creates an OTP URI for specified user and secret.
/// </summary>
/// <param name="username">The username of the user.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated URI.</returns>
public Uri CreateUri(string username, OtpSecret secret, long counter = 0);
/// <summary>
/// Creates an OTP code for specified user and secret.
/// </summary>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
public OtpCode GenerateCode(OtpSecret secret, long counter = 0);
/// <summary>
/// Validates an OTP code for specified user and secret.
/// </summary>
/// <param name="code">The code to validate.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="resyncValue">The resync value. Shows how much the code is ahead or behind the current counter value.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns><c>true</c> if the code is valid; otherwise, <c>false</c>.</returns>
public bool ValidateCode(OtpCode code, OtpSecret secret, out int resyncValue, long counter = 0);
}
@@ -0,0 +1,57 @@
using System.Collections.Specialized;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides options for the One-Time Password service.
/// </summary>
public class OtpOptions
{
/// <summary>
/// The name of the issuer.
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// The issuer domain.
/// </summary>
/// <remarks>
/// <b>IMPORTANT:</b> Using this property will imply adherence to the Apple specification.
/// </remarks>
public string? IssuerDomain { get; set; }
/// <summary>
/// The algorithm to use.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// The number of digits in the OTP code.
/// </summary>
public int Digits { get; set; } = 6;
/// <summary>
/// The number of seconds between each OTP code.
/// </summary>
public int Period { get; set; } = 30;
/// <summary>
/// The type of One-Time Password to generate.
/// </summary>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// The format of OTP URIs.
/// </summary>
public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google | OtpUriFormat.Minimal;
/// <summary>
/// The tolerance span for the OTP codes validation.
/// </summary>
public ToleranceSpan ToleranceSpan { get; set; } = ToleranceSpan.Default;
/// <summary>
/// Custom properties to place in OTP URIs.
/// </summary>
public NameValueCollection CustomProperties { get; } = [];
}
@@ -0,0 +1,95 @@
using System.Collections.Specialized;
using SimpleOTP.Fluent;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides methods for generating and validating One-Time Passwords.
/// </summary>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
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;
/// <summary>
/// Creates an OTP URI for specified user and secret.
/// </summary>
/// <param name="username">The username of the user.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated URI.</returns>
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);
}
/// <summary>
/// Creates an OTP code for specified user and secret.
/// </summary>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated code.</returns>
/// <exception cref="NotSupportedException">The service was not configured properly. Check the "Authenticator:Type" configuration.</exception>
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();
}
/// <summary>
/// Validates an OTP code for specified user and secret.
/// </summary>
/// <param name="code">The code to validate.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="resyncValue">The resync value. Shows how much the code is ahead or behind the current counter value.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns><c>true</c> if the code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="NotSupportedException">The service was not configured properly. Check the "Authenticator:Type" configuration.</exception>
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);
}
}
@@ -0,0 +1,76 @@
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Configuration for the One-Time Password service.
/// </summary>
public class OtpServiceConfig
{
/// <summary>
/// The name of the issuer.
/// </summary>
public string Issuer { get; set; } = null!;
/// <summary>
/// The issuer domain.
/// </summary>
/// <remarks>
/// <b>IMPORTANT:</b> Using this property will imply adherence to the Apple specification.
/// </remarks>
public string? IssuerDomain { get; set; }
/// <summary>
/// The algorithm to use.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// The number of digits in the OTP code.
/// </summary>
public int Digits { get; set; } = 6;
/// <summary>
/// The number of seconds between each OTP code.
/// </summary>
public int Period { get; set; } = 30;
/// <summary>
/// The type of One-Time Password to generate.
/// </summary>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// The format of OTP URIs.
/// </summary>
public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google;
/// <summary>
/// Whether to use minimal URI formatting (only required, or altered properties are included), or full URI formatting.
/// </summary>
public bool MinimalUri { get; set; } = true;
/// <summary>
/// The tolerance span for the OTP codes validation.
/// </summary>
public ToleranceSpanConfig ToleranceSpan { get; set; } = new();
/// <summary>
/// Custom properties to place in OTP URIs.
/// </summary>
public Dictionary<string, string> CustomProperties { get; } = [];
}
/// <summary>
/// Configuration for the tolerance span.
/// </summary>
public class ToleranceSpanConfig
{
/// <summary>
/// The number of periods/counter values behind the current value.
/// </summary>
public int Behind { get; set; } = ToleranceSpan.Default.Behind;
/// <summary>
/// The number of periods/counter values ahead of the current value.
/// </summary>
public int Ahead { get; set; } = ToleranceSpan.Default.Ahead;
}
@@ -0,0 +1,96 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Extension methods for the One-Time Password service.
/// </summary>
public static class OtpServiceExtensions
{
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="issuerName">The issuer/application/service name.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName) =>
AddAuthenticator(services, issuerName);
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="issuerName">The issuer/application/service name.</param>
/// <param name="configure">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName, Action<OtpOptions>? configure = null)
{
OtpOptions options = new()
{
Issuer = issuerName
};
configure?.Invoke(options);
services.AddTransient<IOtpService, OtpService>(_ => new OtpService(options));
return services;
}
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration) =>
AddAuthenticator(services, configuration);
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
/// <param name="configure">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration, Action<OtpOptions>? configure = null)
{
OtpOptions? options = GetOptionsFromConfiguration(configuration);
if (options is null)
return services;
configure?.Invoke(options);
services.AddTransient<IOtpService, OtpService>(_ => 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<string, string> pair in config.CustomProperties)
options.CustomProperties.Add(pair.Key, pair.Value);
return options;
}
}
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<PackageId>EugeneFox.SimpleOTP.DependencyInjection</PackageId>
<Version>8.0.0.0-rc1</Version>
<Authors>Eugene Fox</Authors>
<Copyright>Copyright © Eugene Fox 2024</Copyright>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/XFox111/SimpleOTP.git</RepositoryUrl>
<PackageProjectUrl>https://github.com/XFox111/SimpleOTP</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup>
<PackageTags>
otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth;services;dependency-injection;di</PackageTags>
<Description>
Dependency Injection implementation for SimpleOTP library. Allows to use SimpleOTP as DI
service in your application.
</Description>
<PackageReleaseNotes>
Initial release. See README.md for details.
</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\assets\icon.png">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleOTP\SimpleOTP.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="8.0.*" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SimpleOTP.Tests")]
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpAlgorithm"/>.
/// </summary>
public class OtpAlgorithmJsonConverter : JsonConverter<OtpAlgorithm>
{
/// <inheritdoc/>
public override OtpAlgorithm Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetString() ?? OtpAlgorithm.SHA1);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpAlgorithm value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString());
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpCode"/>.
/// </summary>
public class OtpCodeJsonConverter : JsonConverter<OtpCode>
{
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
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();
}
}
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpConfig"/>.
/// </summary>
public class OtpConfigJsonConverter : JsonConverter<OtpConfig>
{
/// <inheritdoc/>
public override OtpConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
OtpConfig.ParseUri(reader.GetString()!);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpConfig value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToUri().AbsoluteUri);
}
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpSecret"/>.
/// </summary>
public class OtpSecretJsonConverter : JsonConverter<OtpSecret>
{
/// <inheritdoc/>
public override OtpSecret Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetString()!);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpSecret value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString());
}
@@ -0,0 +1,120 @@
namespace SimpleOTP.Encoding;
/// <summary>
/// Provides methods for encoding and decoding data using the RFC 4648 Base32 standard alphabet.
/// </summary>
public class Base32Encoder : IEncoder
{
/// <summary>
/// Gets the singleton instance of the <see cref="Base32Encoder"/> class.
/// </summary>
public static Base32Encoder Instance { get; } = new();
/// <inheritdoc />
public virtual string Scheme => "base32";
/// <summary>
/// Converts a byte array to a Base32 string representation.
/// </summary>
/// <param name="bytes">The byte array to convert.</param>
/// <returns>The Base32 string representation of the byte array.</returns>
/// <exception cref="ArgumentNullException">Thrown when parameter is null.</exception>
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);
}
/// <summary>
/// Converts a Base32 encoded string to a byte array.
/// </summary>
/// <param name="inArray">The Base32 encoded string to convert.</param>
/// <remarks>Trailing bits are ignored (e.g. AAAR will be treated as AAAQ - 0x00 0x01).</remarks>
/// <returns>The byte array representation of the Base32 encoded string.</returns>
/// <exception cref="ArgumentNullException">Thrown when parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="inArray"/> is empty, whitespace, or contains invalid characters.</exception>
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;
}
/// <summary>
/// Converts a Base32 character to its numeric value.
/// </summary>
/// <param name="c">The Base32 character to convert.</param>
/// <returns>The numeric value of the Base32 character.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="c"/> is not a valid Base32 character.</exception>
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)),
};
/// <summary>
/// Converts a numeric value to its Base32 character.
/// </summary>
/// <param name="value">The numeric value to convert.</param>
/// <returns>The Base32 character corresponding to the numeric value.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="value"/> is not a valid Base32 value.</exception>
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)),
};
}
+26
View File
@@ -0,0 +1,26 @@
namespace SimpleOTP.Encoding;
/// <summary>
/// Provides methods for encoding and decoding data using the RFC 4648 Base32 "Extended Hex" alphabet.
/// </summary>
public interface IEncoder
{
/// <summary>
/// Gets the encoding scheme used by the encoder (e.g. <c>base32</c> or <c>base32hex</c>).
/// </summary>
public string Scheme { get; }
/// <summary>
/// Converts a byte array to a Base32 string representation.
/// </summary>
/// <param name="data">The byte array to convert.</param>
/// <returns>The Base32 string representation of the byte array.</returns>
public string EncodeBytes(byte[] data);
/// <summary>
/// Converts a Base32 encoded string to a byte array.
/// </summary>
/// <param name="data">The Base32 encoded string to convert.</param>
/// <returns>The byte array representation of the Base32 encoded string.</returns>
public byte[] GetBytes(string data);
}
+40
View File
@@ -0,0 +1,40 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Class used to streamline OTP code generation on client devices.
/// </summary>
public static class OtpBuilder
{
/// <summary>
/// Use TOTP generator with optional counter period.
/// </summary>
/// <param name="period">Period in seconds.</param>
/// <returns><see cref="Otp"/> instance.</returns>
public static Otp UseTotp(int period = 30) =>
new Totp(OtpSecret.CreateNew(), period);
/// <summary>
/// Use HOTP generator with optional counter value.
/// </summary>
/// <param name="counter">Counter value.</param>
/// <returns><see cref="Otp"/> instance.</returns>
public static Otp UseHotp(long counter = 0) =>
new Hotp(OtpSecret.CreateNew(), counter);
/// <summary>
/// Creates <see cref="Otp"/> instance from <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="config"><see cref="OtpConfig"/> object.</param>
/// <returns><see cref="Otp"/> instance.</returns>
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;
}
}
@@ -0,0 +1,51 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Class used to streamline OTP code configuration on client devices.
/// </summary>
public static class OtpConfigBuilder
{
/// <summary>
/// Use TOTP configuration with optional counter period.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="period">Period in seconds.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
public static OtpConfig UseTotp(string accountName, int period = 30) =>
new(accountName)
{
Type = OtpType.Totp,
Period = period
};
/// <summary>
/// Use HOTP configuration with optional counter.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="counter">Counter value.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
public static OtpConfig UseHotp(string accountName, long counter = 0) =>
new(accountName)
{
Type = OtpType.Hotp,
Counter = counter
};
/// <summary>
/// Use TOTP which satisfies Apple's specification requirements.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="issuerName">Issuer/application/service display name.</param>
/// <param name="issuerDomain">Issuer/application/service domain name.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
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
};
}
@@ -0,0 +1,118 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Provides fluent API for configuring <see cref="OtpConfig"/> objects.
/// </summary>
public static class OtpConfigFluentExtensions
{
/// <summary>
/// Sets the <see cref="OtpConfig.Label"/> property.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="label">The label of the OTP config.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithLabel(this OtpConfig config, string label)
{
config.Label = label;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Issuer"/> property.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithIssuer(this OtpConfig config, string? issuer)
{
config.Issuer = issuer;
return config;
}
/// <summary>
/// Sets the issuer info, according to Apple specification.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="displayName">The display name of the issuer.</param>
/// <param name="domain">The domain name of the issuer.</param>
public static OtpConfig WithAppleIssuer(this OtpConfig config, string displayName, string domain)
{
config.IssuerLabel = displayName;
config.Issuer = domain;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Secret"/> property with a new secret.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="bytesLength">The length of the secret in bytes.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithNewSecret(this OtpConfig config, int bytesLength)
{
config.Secret = OtpSecret.CreateNew(bytesLength);
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Secret"/> property with specified secret.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="secret">The secret to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithSecret(this OtpConfig config, OtpSecret secret)
{
config.Secret = secret;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Algorithm"/> property.
/// </summary>
/// <remarks>Not recommended for use, since most implementations do not support custom values.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="algorithm">The algorithm to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithAlgorithm(this OtpConfig config, OtpAlgorithm algorithm)
{
config.Algorithm = algorithm;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Digits"/> property.
/// </summary>
/// <remarks>Not recommended for use, since most implementations do not support custom values.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="digits">The number of digits to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithDigits(this OtpConfig config, int digits)
{
config.Digits = digits;
return config;
}
/// <summary>
/// Adds a custom vendor-specific property to the <see cref="OtpConfig"/>.
/// </summary>
/// <remarks>If set, reserved keys
/// <c>issuer, digits, counter, secret, period and algorithm</c>
/// will be removed from the <see cref="OtpConfig.CustomProperties"/> upon it's serialization to URI.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="key">The key of the property.</param>
/// <param name="value">The value of the property.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig AddCustomProperty(this OtpConfig config, string key, string value)
{
config.CustomProperties.Add(key, value);
return config;
}
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpConfig"/>
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to use.</param>
/// <returns>A new <see cref="Otp"/> object.</returns>
public static Otp CreateGenerator(this OtpConfig config) =>
OtpBuilder.FromConfig(config);
}
@@ -0,0 +1,55 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Provides fluent API for configuring <see cref="Otp"/> objects.
/// </summary>
public static class OtpFluentExtensions
{
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpConfig"/>
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="bytesLength">The length of the secret in bytes.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithNewSecret(this Otp generator, int bytesLength)
{
generator.Secret = OtpSecret.CreateNew(bytesLength);
return generator;
}
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpSecret"/>
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="secret">The <see cref="OtpSecret"/> to use.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithSecret(this Otp generator, OtpSecret secret)
{
generator.Secret = secret;
return generator;
}
/// <summary>
/// Sets the <see cref="Otp.Digits"/> property.
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="digits">The number of digits to use in OTP codes.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithDigits(this Otp generator, int digits)
{
generator.Digits = digits;
return generator;
}
/// <summary>
/// Sets the <see cref="Otp.Algorithm"/> property.
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="algorithm">The algorithm to use.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithAlgorithm(this Otp generator, OtpAlgorithm algorithm)
{
generator.Algorithm = algorithm;
return generator;
}
}
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
namespace SimpleOTP;
/// <summary>
/// Provides methods for registering and retrieving <see cref="KeyedHashAlgorithm"/> providers.
/// </summary>
public static class HashAlgorithmProviders
{
private static readonly Dictionary<OtpAlgorithm, Func<KeyedHashAlgorithm>> _registeredProviders = new()
{
{ OtpAlgorithm.SHA1, () => new HMACSHA1() },
{ OtpAlgorithm.SHA256, () => new HMACSHA256() },
{ OtpAlgorithm.SHA512, () => new HMACSHA512() },
{ OtpAlgorithm.MD5, () => new HMACMD5() }
};
/// <summary>
/// Registers a new <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to register.</param>
public static void AddProvider<TAlgorithm>(OtpAlgorithm algorithm)
where TAlgorithm : KeyedHashAlgorithm, new() =>
_registeredProviders[algorithm] = () => new TAlgorithm();
/// <summary>
/// Retrieves a <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to retrieve.</param>
/// <returns>The <see cref="KeyedHashAlgorithm"/> provider, or <c>null</c> if not found.</returns>
public static KeyedHashAlgorithm? GetProvider(OtpAlgorithm algorithm)
{
if (_registeredProviders.TryGetValue(algorithm, out var provider))
return provider();
return null;
}
/// <summary>
/// Removes a <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to remove.</param>
public static void RemoveProvider(OtpAlgorithm algorithm) =>
_registeredProviders.Remove(algorithm);
/// <summary>
/// Determines whether a <see cref="KeyedHashAlgorithm"/> provider is registered.
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><c>true</c> if the <see cref="KeyedHashAlgorithm"/> provider is registered; otherwise, <c>false</c>.</returns>
public static bool IsRegistered(OtpAlgorithm algorithm) =>
_registeredProviders.ContainsKey(algorithm);
/// <summary>
/// Removes all registered <see cref="KeyedHashAlgorithm"/> providers.
/// </summary>
/// <remarks>This method also clears default providers. Use with caution.</remarks>
public static void ClearProviders() => _registeredProviders.Clear();
}
+60
View File
@@ -0,0 +1,60 @@
namespace SimpleOTP;
/// <summary>
/// Represents a HOTP (HMAC-based One-Time Password) generator.
/// </summary>
public class Hotp : Otp
{
/// <summary>
/// Gets or sets the counter value used for generating OTP codes.
/// </summary>
public long Counter { get; set; } = 0;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Hotp(OtpSecret secret) : base(secret) { }
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
public Hotp(OtpSecret secret, long counter) : base(secret) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Hotp(OtpSecret secret, long counter, int digits) : base(secret, digits) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm) : base(secret, algorithm) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) =>
Counter = counter;
/// <summary>
/// Gets the current counter value.
/// </summary>
/// <returns>The current counter value.</returns>
protected override long GetCounter() => Counter;
}
+212
View File
@@ -0,0 +1,212 @@
using System.Security.Cryptography;
namespace SimpleOTP;
// TODO: Add tests
/// <summary>
/// Represents an abstract class for generating and validating One-Time Passwords (OTP).
/// </summary>
public abstract class Otp
{
#region Properties
/// <summary>
/// Gets or sets the secret key used for generating OTPs.
/// </summary>
public OtpSecret Secret { get; set; }
/// <summary>
/// Gets or sets the algorithm used for generating OTP codes.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// Gets or sets the number of digits in the OTP code.
/// </summary>
/// <value>Default: 6. Recommended: 6-8.</value>
public int Digits { get; set; } = 6;
#endregion
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Otp(OtpSecret secret, OtpAlgorithm algorithm, int digits) =>
(Secret, Algorithm, Digits) = (secret, algorithm, digits);
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Otp(OtpSecret secret, OtpAlgorithm algorithm) : this(secret, algorithm, 6) { }
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Otp(OtpSecret secret, int digits) : this(secret, OtpAlgorithm.SHA1, digits) { }
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Otp(OtpSecret secret) : this(secret, OtpAlgorithm.SHA1, 6) { }
#endregion
#region Methods
// Generate
/// <summary>
/// Generates an OTP code.
/// </summary>
/// <returns>The generated OTP code.</returns>
public OtpCode Generate() =>
Generate(GetCounter());
/// <summary>
/// Generates an OTP code for the specified counter value.
/// </summary>
/// <param name="counter">The counter value to generate the OTP code for.</param>
/// <returns>The generated OTP code.</returns>
public virtual OtpCode Generate(long counter) =>
new(Compute(counter), Digits);
// Validate
/// <summary>
/// Validates an OTP code.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code) =>
Validate(code, (1, 1));
/// <summary>
/// Validates an OTP code with tolerance.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance) =>
Validate(code, tolerance, out _);
/// <summary>
/// Validates an OTP code with tolerance and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance, out int resyncValue) =>
Validate(code, tolerance, GetCounter(), out resyncValue);
/// <summary>
/// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="baseCounter">The base counter value.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
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;
}
/// <summary>
/// Gets the current counter value.
/// </summary>
/// <returns>The current counter value.</returns>
protected abstract long GetCounter();
/// <summary>
/// Computes the OTP code for the specified counter value.
/// </summary>
/// <param name="counter">The counter value to compute the OTP code for.</param>
/// <returns>The OTP code for the specified counter value.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
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);
}
/// <summary>
/// Computes the OTP code for the specified counter value using provided hash algorithm.
/// </summary>
/// <param name="counter">The counter value to compute the OTP code for.</param>
/// <param name="hashAlgorithm">The hash algorithm to use for computing the OTP code.</param>
/// <remarks>You need to dispose of the <paramref name="hashAlgorithm"/> object yourself when you are done using it.</remarks>
/// <returns>The OTP code for the specified counter value.</returns>
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
}
+151
View File
@@ -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;
/// <summary>
/// Represents the hashing algorithm used for One-Time Passwords.
/// </summary>
[Serializable]
[JsonConverter(typeof(OtpAlgorithmJsonConverter))]
public readonly partial struct OtpAlgorithm : IEquatable<OtpAlgorithm>, IEquatable<string>, IXmlSerializable
{
/// <summary>
/// The HMAC-SHA1 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA1 { get; } = new("SHA1");
/// <summary>
/// The HMAC-SHA256 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA256 { get; } = new("SHA256");
/// <summary>
/// The HMAC-SHA512 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA512 { get; } = new("SHA512");
/// <summary>
/// The HMAC-MD5 hashing algorithm.
/// </summary>
/// <remarks>
/// This is not a standard algorithm, but it is defined by IIJ specification and recognized by default.<br />
/// <a href="https://www1.auth.iij.jp/smartkey/en/uri_v1.html">Internet Initiative Japan. URI format</a>
/// </remarks>
public static OtpAlgorithm MD5 { get; } = new("MD5");
private readonly string _value;
/// <summary>
/// Initializes a new instance of the <see cref="OtpAlgorithm"/> struct.
/// </summary>
/// <param name="value">The algorithm to use.</param>
/// <exception cref="ArgumentException">Thrown if <paramref name="value"/> is empty or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="value"/> is <see langword="null"/>.</exception>
public OtpAlgorithm(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (StandardAlgorithmsRegex().IsMatch(value))
_value = StandardAlgorithmsRegex().Match(value).Value.ToUpperInvariant();
else
_value = value.ToUpperInvariant();
}
/// <inheritdoc/>
public bool Equals(OtpAlgorithm other) =>
_value == other._value;
/// <inheritdoc/>
public bool Equals(string? other)
{
if (string.IsNullOrWhiteSpace(other))
return _value is null;
if (_value is null)
return false;
return Equals(new OtpAlgorithm(other));
}
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpAlgorithm algorithm && Equals(algorithm);
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public bool IsStandard() =>
IsStandard(_value);
/// <inheritdoc/>
public override int GetHashCode() => _value.GetHashCode();
/// <summary>
/// Returns the string representation of the <see cref="OtpAlgorithm"/> struct.
/// </summary>
/// <returns>The string representation of the <see cref="OtpAlgorithm"/> struct.</returns>
public override string ToString() => _value;
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
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
}
/// <inheritdoc/>
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
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public static bool IsStandard(string algorithm) =>
StandardAlgorithmsRegex().IsMatch(algorithm);
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public static bool IsStandard(OtpAlgorithm algorithm) =>
IsStandard(algorithm._value);
[GeneratedRegex(@"(?<=Hmac)?SHA(1|256|512)", RegexOptions.IgnoreCase, "")]
private static partial Regex StandardAlgorithmsRegex();
}
+111
View File
@@ -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
/// <summary>
/// Represents a one-time password (OTP) code.
/// </summary>
public readonly partial struct OtpCode : IEquatable<OtpCode>, IEquatable<string>
{
private readonly int _value;
private readonly int _digits;
/// <summary>
/// Gets a value indicating whether the OTP code can expire (<c>true</c> for TOTP, <c>false</c> for HOTP).
/// </summary>
public readonly bool CanExpire => ExpirationTime is not null;
/// <summary>
/// Gets the expiration time of the OTP code (TOTP only).
/// </summary>
public readonly DateTimeOffset? ExpirationTime { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value with no expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(int code, int digits) : this(code, digits, null) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value and the expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
/// <param name="expirationTime">The expiration time of the OTP code (TOTP only).</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(int code, int digits, DateTimeOffset? expirationTime = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(code);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(digits);
_value = code;
_digits = digits;
ExpirationTime = expirationTime;
}
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value with no expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(string code) : this(code, null) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value and the expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="expirationTime">The expiration time of the OTP code (TOTP only).</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
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;
}
/// <summary>
/// Returns a string representation of the OTP code.
/// </summary>
/// <returns>A string representation of the OTP code.</returns>
public override readonly string ToString() =>
_value.ToString($"D{_digits}");
/// <summary>
/// Returns a string representation of the OTP code.
/// </summary>
/// <param name="format">The format to use.</param>
/// <returns>The string representation of the OTP code.</returns>
public readonly string ToString(string? format) =>
_value.ToString(format);
/// <inheritdoc/>
public bool Equals(OtpCode other) =>
ToString() == other.ToString();
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpCode code && Equals(code);
/// <inheritdoc/>
public bool Equals(string? other) =>
ToString() == other;
/// <inheritdoc/>
public override int GetHashCode() =>
_value.GetHashCode();
}
@@ -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
{
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
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
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
if (ExpirationTime.HasValue)
writer.WriteAttributeString("expiring", ExpirationTime.Value.ToString("O"));
writer.WriteString(ToString());
}
}
@@ -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
/// <summary>
/// Parses the specified <see cref="string"/> into an <see cref="OtpCode"/> object.
/// </summary>
/// <param name="code">The string to parse.</param>
/// <returns>An <see cref="OtpCode"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public static OtpCode Parse(string code) =>
new(code);
/// <summary>
/// Tries to parse the specified <see cref="string"/> into an <see cref="OtpCode"/> object.
/// </summary>
/// <param name="code">The string to parse.</param>
/// <param name="result">The parsed <see cref="OtpCode"/> object.</param>
/// <returns><see langword="true"/> if <paramref name="code"/> was parsed successfully; otherwise, <see langword="false"/>.</returns>
public static bool TryParse(string code, out OtpCode result)
{
try
{
result = new(code);
return true;
}
catch
{
result = default;
return false;
}
}
}
@@ -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
/// <summary>
/// Represents the configuration for a One-Time Password (OTP).
/// </summary>
public partial record class OtpConfig
{
/// <summary>
/// Gets or sets the type of the OTP.
/// </summary>
/// <value>Default is: <see cref="OtpType.Totp"/></value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.1">Internet-Draft</a>.<br />
/// <b>IMPORTANT:</b> Some authenticators do not support <see cref="OtpType.Hotp"/>.
/// </remarks>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// Gets or sets the issuer label prefix of the OTP.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>Not recommended for use in most cases.</item>
/// <item>Most authenticators do not support this prefix and mess with the <see cref="Label"/> string.</item>
/// <item>Required if you intend to use <see cref="OtpUriFormat.Apple"/>. Use this prefix to set the issuer display name.</item>
/// </list>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.2">Internet-Draft</a>.
/// </remarks>
public string? IssuerLabel { get; set; }
/// <summary>
/// Gets or sets the label of the OTP.
/// </summary>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.2">Internet-Draft</a>.
/// </remarks>
public string Label { get; set; }
/// <summary>
/// Gets or sets the secret of the OTP.
/// </summary>
/// <value>Default: 160-bit key. Minimal recommended: 128 bits</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.1">Internet-Draft</a>
/// </remarks>
public OtpSecret Secret { get; set; } = OtpSecret.CreateNew();
/// <summary>
/// Gets or sets the hashing algorithm of the OTP.
/// </summary>
/// <value>Default: <see cref="OtpAlgorithm.SHA1"/></value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.2">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support algorithms other than <see cref="OtpAlgorithm.SHA1"/>.
/// </remarks>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// Gets or sets the issuer of the OTP. Optional.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>Use this property instead of <see cref="IssuerLabel"/>.</item>
/// <item>Required if you intend to use <see cref="OtpUriFormat.Apple"/>. Use this property to set the issuer domain name.</item>
/// </list>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.6">Internet-Draft</a>
/// </remarks>
public string? Issuer { get; set; }
/// <summary>
/// Gets or sets the number of digits of the OTP codes.
/// </summary>
/// <value>Default: 6. Recommended: 6 or 8</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.3">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support digits other than 6.
/// </remarks>
public int Digits { get; set; } = 6;
/// <summary>
/// Gets or sets the counter of the OTP. Required for <see cref="OtpType.Hotp"/>. Ignored for <see cref="OtpType.Totp"/>.
/// </summary>
/// <value>Default: 0</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.4">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support <see cref="OtpType.Hotp"/>.
/// </remarks>
public long Counter { get; set; } = 0;
/// <summary>
/// Gets or sets the period of the OTP in seconds. Optional for <see cref="OtpType.Totp"/>. Ignored for <see cref="OtpType.Hotp"/>.
/// </summary>
/// <value>Default: 30</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.5">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators support only periods of 30 seconds.
/// </remarks>
public int Period { get; set; } = 30;
/// <summary>
/// Gets the custom vendor-specified properties of the current OTP configuration.
/// </summary>
/// <remarks>
/// If set, reserved keys
/// <c>issuer, digits, counter, secret, period and algorithm</c>
/// will be removed from the <see cref="CustomProperties"/> upon it's serialization to URI.<br />
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.7">Internet-Draft</a>
/// </remarks>
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)
];
}
@@ -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
{
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
public OtpConfig(string label) =>
Label = label;
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
public OtpConfig(string label, string issuer) =>
(Label, Issuer) = (label, issuer);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="secret">The secret of the OTP config.</param>
public OtpConfig(string label, OtpSecret secret) =>
(Label, Secret) = (label, secret);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
/// <param name="secret">The secret of the OTP config.</param>
public OtpConfig(string label, string issuer, OtpSecret secret) =>
(Label, Issuer, Secret) = (label, issuer, secret);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP config.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public OtpConfig(Uri uri) : this(uri, OtpSecret.DefaultEncoder) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP config.</param>
/// <param name="encoder">The encoder used to decode the secret.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
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);
}
}
@@ -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
{
/// <summary>
/// Converts the current <see cref="OtpConfig"/> object to a <see cref="Uri"/> object.
/// </summary>
/// <remarks>
/// Uses minimal Google specified formatting (<see cref="OtpUriFormat.Minimal"/> | <see cref="OtpUriFormat.Google"/>).
/// </remarks>
/// <returns>A <see cref="Uri"/> object representing the current <see cref="OtpConfig"/> object.</returns>
public Uri ToUri() =>
ToUri(OtpUriFormat.Minimal | OtpUriFormat.Google);
/// <summary>
/// Converts the current <see cref="OtpConfig"/> object to a <see cref="Uri"/> object.
/// </summary>
/// <param name="format">A bitwise combination of the enumeration values that specifies the format of the URI.</param>
/// <returns>A <see cref="Uri"/> object representing the current <see cref="OtpConfig"/> object.</returns>
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;
}
/// <summary>
/// Returns if the specified <see cref="OtpConfig"/> object is valid.
/// </summary>
/// <param name="error">The error message returned if the <see cref="OtpConfig"/> object is invalid.</param>
/// <param name="format">The <see cref="OtpUriFormat"/> to use for validation.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public bool IsValid([NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google) =>
Validate(this, out error, format);
/// <inheritdoc/>
public override string ToString() =>
ToUri().AbsoluteUri;
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
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
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
writer.WriteString(ToUri().AbsoluteUri);
}
}
@@ -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
/// <summary>
/// Parses the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>An <see cref="OtpConfig"/> object parsed from the specified URI.</returns>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(Uri uri) =>
new(uri);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP.</param>
/// <param name="encoder">The encoder used to decode the secret.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(Uri uri, IEncoder encoder) =>
new(uri, encoder);
/// <summary>
/// Parses the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>An <see cref="OtpConfig"/> object parsed from the specified URI.</returns>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
/// <exception cref="UriFormatException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(string uri) =>
new(new Uri(uri));
/// <summary>
/// Tries to parse the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <param name="config">When this method returns, contains the <see cref="OtpConfig"/> object parsed from the specified URI, if the conversion succeeded, or <c>null</c> if the conversion failed.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool TryParseUri(Uri uri, [NotNullWhen(true)] out OtpConfig? config)
{
try
{
config = new(uri);
return true;
}
catch
{
config = null;
return false;
}
}
/// <summary>
/// Tries to parse the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <param name="config">When this method returns, contains the <see cref="OtpConfig"/> object parsed from the specified URI, if the conversion succeeded, or <c>null</c> if the conversion failed.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool TryParseUri(string uri, [NotNullWhen(true)] out OtpConfig? config) =>
TryParseUri(new Uri(uri), out config);
/// <summary>
/// Returns if the specified <see cref="OtpConfig"/> object is valid.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to validate.</param>
/// <param name="error">The error message returned if the <see cref="OtpConfig"/> object is invalid.</param>
/// <param name="format">The <see cref="OtpUriFormat"/> to use for validation.</param>
/// <remarks>The <paramref name="format"/> should contain at least one vendor-specific format.</remarks>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool Validate(OtpConfig config, [NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google)
{
List<string> 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;
}
}
@@ -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
/// <summary>
/// Represents a one-time password secret.
/// </summary>
public partial class OtpSecret : IEquatable<OtpSecret>, IEquatable<byte[]>, IXmlSerializable, IDisposable
{
private readonly byte[] _secret;
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class with a default length of 20 bytes (160 bits).
/// </summary>
public OtpSecret() : this(20) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class with a random secret of the specified length.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <param name="length">The length of the secret in bytes.</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="length"/> is less than 1.</exception>
public OtpSecret(int length) =>
_secret = RandomNumberGenerator.GetBytes(length);
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from a byte array.
/// </summary>
/// <param name="secret">The byte array.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c> or empty.</exception>
public OtpSecret(byte[] secret)
{
if (secret is null || secret.Length < 1)
throw new ArgumentNullException(nameof(secret));
_secret = secret;
}
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from a Base32-encoded string (RFC 4648 §6).
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public OtpSecret(string secret) : this(secret, DefaultEncoder) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from an encoded string.
/// </summary>
/// <param name="secret">The encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c> or empty.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public OtpSecret(string secret, IEncoder encoder)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secret, nameof(secret));
_secret = encoder.GetBytes(secret);
}
/// <summary>
/// Returns the Base32-encoded string representation of the current <see cref="OtpSecret"/> object.
/// </summary>
/// <returns>The Base32-encoded string representation of the current <see cref="OtpSecret"/> object.</returns>
public override string ToString() =>
DefaultEncoder.EncodeBytes(_secret);
/// <summary>
/// Returns the string representation of the current <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="encoder">The encoder.</param>
/// <returns>The string representation of the current <see cref="OtpSecret"/> object.</returns>
public string ToString(IEncoder encoder) =>
encoder.EncodeBytes(_secret);
/// <inheritdoc/>
public bool Equals(OtpSecret? other) =>
other is not null && _secret.SequenceEqual(other._secret);
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpSecret other && Equals(other);
/// <inheritdoc/>
public bool Equals(byte[]? other) =>
other is not null && _secret.SequenceEqual(other);
/// <inheritdoc/>
public override int GetHashCode() =>
new BigInteger(_secret ?? []).GetHashCode();
/// <inheritdoc/>
public void Dispose()
{
Array.Clear(_secret, 0, _secret.Length);
GC.SuppressFinalize(this);
}
}
@@ -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
{
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
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
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
writer.WriteAttributeString("encoding", DefaultEncoder.Scheme);
writer.WriteAttributeString("length", _secret.Length.ToString());
writer.WriteString(DefaultEncoder.EncodeBytes(_secret));
}
}
@@ -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
{
/// <summary>
/// Gets or sets the default encoder for parsing/encoding/serializing secrets.
/// </summary>
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
/// <summary>
/// Creates a copy of the specified <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="source">The <see cref="OtpSecret"/> object to copy.</param>
/// <returns>A copy of the specified <see cref="OtpSecret"/> object.</returns>
public static OtpSecret CreateCopy(OtpSecret source)
{
byte[] bytes = new byte[source._secret.Length];
Array.Copy(source._secret, bytes, source._secret.Length);
return new(bytes);
}
/// <summary>
/// Creates a new random <see cref="OtpSecret"/> object with a default length of 20 bytes.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <returns>A new random <see cref="OtpSecret"/> object.</returns>
public static OtpSecret CreateNew() =>
new();
/// <summary>
/// Creates a new random <see cref="OtpSecret"/> object with the specified length.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <param name="length">The length of the secret in bytes.</param>
/// <returns>A new random <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="length"/> is less than 1.</exception>
public static OtpSecret CreateNew(int length) =>
new(length);
/// <summary>
/// Parses a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public static OtpSecret Parse(string secret) =>
new(secret);
/// <summary>
/// Parses a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public static OtpSecret Parse(string secret, IEncoder encoder) =>
new(secret, encoder);
/// <summary>
/// Tries to parse a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="otpSecret">When this method returns, contains the <see cref="OtpSecret"/> object, if the conversion succeeded, or <c>default</c> if the conversion failed.</param>
/// <returns><c>true</c> if <paramref name="secret"/> was converted successfully; otherwise, <c>false</c>.</returns>
public static bool TryParse(string secret, out OtpSecret? otpSecret) =>
TryParse(secret, DefaultEncoder, out otpSecret);
/// <summary>
/// Tries to parse a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <param name="otpSecret">When this method returns, contains the <see cref="OtpSecret"/> object, if the conversion succeeded, or <c>default</c> if the conversion failed.</param>
/// <returns><c>true</c> if <paramref name="secret"/> was converted successfully; otherwise, <c>false</c>.</returns>
public static bool TryParse(string secret, IEncoder encoder, out OtpSecret? otpSecret)
{
try
{
otpSecret = new(secret, encoder);
return true;
}
catch
{
otpSecret = null;
return false;
}
}
/// <summary>
/// Creates a new <see cref="OtpSecret"/> object from a byte array.
/// </summary>
/// <param name="secret">The byte array.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="secret"/> is empty.</exception>
public static OtpSecret FromBytes(byte[] secret) =>
new(secret);
}
+22
View File
@@ -0,0 +1,22 @@
namespace SimpleOTP;
/// <summary>
/// Represents the type of One-Time Password (OTP).
/// </summary>
public enum OtpType
{
/// <summary>
/// Time-based One-Time Password (TOTP).
/// </summary>
/// <remarks>
/// <a href="https://tools.ietf.org/html/rfc6238">RFC 6238</a>
/// </remarks>
Totp = 0,
/// <summary>
/// HMAC-based One-Time Password (HOTP).
/// </summary>
/// <remarks>
/// <a href="https://tools.ietf.org/html/rfc4226">RFC 4226</a>
/// </remarks>
Hotp = 1
}
+67
View File
@@ -0,0 +1,67 @@
namespace SimpleOTP;
/// <summary>
/// Bitwise flags for specifying the format of One-Time Password (OTP) URIs.
/// </summary>
public enum OtpUriFormat : ushort
{
/// <summary>
/// Represents a minimal URI format - only non-default properties are included.
/// </summary>
/// <remarks>
/// This is the default format.
/// </remarks>
Minimal = 0b_0000_0001,
/// <summary>
/// Represents a full URI format - all properties are included.
/// </summary>
Full = 0b_0000_0010,
/// <summary>
/// Represents a Google URI format.
/// </summary>
/// <remarks>
/// This is the default format.<br />
/// <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Google Authenticator. Key Uri Format</a>
/// </remarks>
Google = 0b_0001_0000,
/// <summary>
/// Represents an Apple URI format.
/// </summary>
/// <remarks>
/// <a href="https://developer.apple.com/documentation/authenticationservices/securing_logins_with_icloud_keychain_verification_codes">
/// Apple. Securing Logins with iCloud Keychain Verification Codes
/// </a>
/// </remarks>
Apple = 0b_0010_0000,
/// <summary>
/// Represents an IBM URI format.
/// </summary>
/// <remarks>
/// <a href="https://www.ibm.com/docs/en/sva/9.0.6?topic=authentication-configuring-totp-one-time-password-mechanism">
/// IBM. Authentication Configuring TOTP One-Time Password Mechanism
/// </a>
/// </remarks>
IBM = 0b_0100_0000,
/// <summary>
/// Represents a Yubico URI format.
/// </summary>
/// <remarks>
/// <a href="https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html">
/// Yubico. URI String Format
/// </a>
/// </remarks>
Yubico = 0b_1000_0000,
/// <summary>
/// Represents an IIJ URI format.
/// </summary>
/// <remarks>
/// <a href="https://www1.auth.iij.jp/smartkey/en/uri_v1.html">Internet Initiative Japan. URI format</a>
/// </remarks>
IIJ = 0b_0001_0000_0000
}
+53
View File
@@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup>
<PackageId>EugeneFox.SimpleOTP</PackageId>
<Version>8.0.0.0-rc1</Version>
<Authors>Eugene Fox</Authors>
<Copyright>Copyright © Eugene Fox 2024</Copyright>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/XFox111/SimpleOTP.git</RepositoryUrl>
<PackageProjectUrl>https://github.com/XFox111/SimpleOTP</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<PackageTags>otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth</PackageTags>
<Description>
Feature-rich, fast, and customizable library for implementation TOTP/HOTP authenticators and validators.
</Description>
<PackageReleaseNotes>
(BREAKING CHANGE) Complete overhaul of the library. See https://github.com/XFox111/SimpleOTP/releases/tag/2.0.0 for more details.
</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\assets\icon.png">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
namespace SimpleOTP;
/// <summary>
/// Represents a span of tolerance values used in OTP (One-Time Password) validation.
/// </summary>
/// <param name="behind">The number of periods/counter values behind the current value.</param>
/// <param name="ahead">The number of periods/counter values ahead of the current value.</param>
public readonly struct ToleranceSpan(int behind, int ahead) : IEquatable<ToleranceSpan>
{
/// <summary>
/// Gets the default recommended <see cref="ToleranceSpan"/> value.
/// </summary>
/// <value>The default <see cref="ToleranceSpan"/> value: 1 counter/period ahead and behind.</value>
public static ToleranceSpan Default { get; } = new(1);
/// <summary>
/// Gets the number of tolerance values behind the current value.
/// </summary>
public int Behind { get; init; } = behind;
/// <summary>
/// Gets the number of tolerance values ahead of the current value.
/// </summary>
public int Ahead { get; init; } = ahead;
/// <summary>
/// Initializes a new instance of the <see cref="ToleranceSpan"/> struct with the specified tolerance value.
/// The <see cref="Behind"/> and <see cref="Ahead"/> properties will be set to the same value.
/// </summary>
/// <param name="tolerance">The tolerance value to set for both <see cref="Behind"/> and <see cref="Ahead"/>.</param>
public ToleranceSpan(int tolerance) : this(tolerance, tolerance) { }
/// <inheritdoc/>
public bool Equals(ToleranceSpan other) =>
Behind == other.Behind && Ahead == other.Ahead;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is ToleranceSpan span && Equals(span);
/// <inheritdoc/>
public override int GetHashCode() =>
HashCode.Combine(Behind, Ahead);
/// <summary>
/// Returns the string representation of the <see cref="ToleranceSpan"/> struct.
/// </summary>
/// <returns>The string representation of the <see cref="ToleranceSpan"/> struct.</returns>
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
}
+98
View File
@@ -0,0 +1,98 @@
namespace SimpleOTP;
/// <summary>
/// Represents a Time-based One-Time Password (TOTP) generator.
/// </summary>
public class Totp : Otp
{
/// <summary>
/// Gets or sets the time period (in seconds) for which each generated OTP is valid.
/// </summary>
/// <remarks>Also used to calculate the current counter value.</remarks>
public int Period { get; set; } = 30;
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Totp(OtpSecret secret) : base(secret) { }
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key and number of OTP code digits.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
public Totp(OtpSecret secret, int period) : base(secret) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, number of OTP code digits, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Totp(OtpSecret secret, int period, int digits) : base(secret, digits) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, hash algorithm, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm) : base(secret, algorithm) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, hash algorithm, number of digits, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) =>
Period = period;
#endregion
/// <summary>
/// Generates an OTP based on the specified counter value.
/// </summary>
/// <param name="counter">The counter value to use for OTP generation.</param>
/// <returns>An instance of <see cref="OtpCode"/> representing the generated OTP.</returns>
public override OtpCode Generate(long counter) =>
new(Compute(counter), Digits, DateTime.UnixEpoch.AddSeconds((counter + 1) * Period));
/// <summary>
/// Generates an OTP based on the specified date and time.
/// </summary>
/// <param name="date">The date and time to use for OTP generation.</param>
/// <returns>An instance of <see cref="OtpCode"/> representing the generated OTP.</returns>
public OtpCode Generate(DateTimeOffset date) =>
Generate(date.ToUnixTimeSeconds() / Period);
/// <summary>
/// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="baseTime">The base timestamp value.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Otp.Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance, DateTimeOffset baseTime, out int resyncValue) =>
Validate(code, tolerance, baseTime.ToUnixTimeSeconds() / 30, out resyncValue);
/// <summary>
/// Gets the current counter value based on the current UTC time and the configured time period.
/// </summary>
/// <returns>The current counter value.</returns>
protected override long GetCounter() =>
DateTimeOffset.UtcNow.ToUnixTimeSeconds() / Period;
}