diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..15c9e97 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# 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 + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" + # Allow up to 10 open pull requests for dependencies + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7f40e50 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,79 @@ +# 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. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: + - "main" + pull_request: + # The branches below must be a subset of the branches above + branches: + - "main" + schedule: + - cron: "19 7 * * 2" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: + - "go" + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..25f4093 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,69 @@ +name: Go + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: 1.24.x + cache: true + - run: go version + + - name: Examine source code for Linux AMD + run: GOOS=linux GOARCH=amd64 go vet -v ./... + + - name: Examine source code for MacOS AMD + run: GOOS=darwin GOARCH=amd64 go vet -v ./... + + - name: Examine source code for Windows AMD + run: GOOS=windows GOARCH=amd64 go vet -v ./... + + - name: Examine source code for Linux ARM + run: GOOS=linux GOARCH=arm64 go vet -v ./... + + - name: Examine source code for MacOS ARM + run: GOOS=darwin GOARCH=arm64 go vet -v ./... + + - name: Examine source code for Windows ARM + run: GOOS=windows GOARCH=arm64 go vet -v ./... + + - name: Test source code + run: go test -v -cover ./... + + - name: Build for Linux AMD + run: GOOS=linux GOARCH=amd64 go build -v ./... + + - name: Build for MacOS AMD + run: GOOS=darwin GOARCH=amd64 go build -v ./... + + - name: Build for Windows AMD + run: GOOS=windows GOARCH=amd64 go build -v ./... + + - name: Build for Linux ARM + run: GOOS=linux GOARCH=arm64 go build -v ./... + + - name: Build for MacOS ARM + run: GOOS=darwin GOARCH=arm64 go build -v ./... + + - name: Build for Windows ARM + run: GOOS=windows GOARCH=arm64 go build -v ./... diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..01bbdc6 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,49 @@ +name: golangci-lint +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: 1.24.x + cache: true + - run: go version + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: v2.6.2 + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + args: --timeout 3m --verbose + + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true + + # Optional: if set to true then the action don't cache or restore ~/go/pkg. + # skip-pkg-cache: true + + # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. + # skip-build-cache: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6bbaab4..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: go - -go: - - 1.6.2 - -install: - - go get "github.com/sec51/qrcode" - - go get "github.com/sec51/cryptoengine" - - go get "github.com/sec51/convert/smallendian" - -script: - - go test -v ./... diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json deleted file mode 100644 index cdf2f68..0000000 --- a/Godeps/Godeps.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "ImportPath": "github.com/sec51/twofactor", - "GoVersion": "go1.6", - "GodepVersion": "v74", - "Deps": [ - { - "ImportPath": "github.com/sec51/convert", - "Rev": "8ed1f399b5e0a9a9620c74cfd5aec3682d8328ab" - }, - { - "ImportPath": "github.com/sec51/convert/bigendian", - "Rev": "8ed1f399b5e0a9a9620c74cfd5aec3682d8328ab" - }, - { - "ImportPath": "github.com/sec51/convert/smallendian", - "Rev": "8ed1f399b5e0a9a9620c74cfd5aec3682d8328ab" - }, - { - "ImportPath": "github.com/sec51/cryptoengine", - "Rev": "11617a465c082a1e82359b3c059f018f8dcbfc93" - }, - { - "ImportPath": "github.com/sec51/gf256", - "Rev": "2454accbeb9e6b0e2e53b01e1d641c7157251ed4" - }, - { - "ImportPath": "github.com/sec51/qrcode", - "Rev": "b7779abbcaf1ec4de65f586a85fe24db31d45e7c" - }, - { - "ImportPath": "github.com/sec51/qrcode/coding", - "Rev": "b7779abbcaf1ec4de65f586a85fe24db31d45e7c" - }, - { - "ImportPath": "golang.org/x/crypto/curve25519", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - }, - { - "ImportPath": "golang.org/x/crypto/hkdf", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - }, - { - "ImportPath": "golang.org/x/crypto/nacl/box", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - }, - { - "ImportPath": "golang.org/x/crypto/nacl/secretbox", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - }, - { - "ImportPath": "golang.org/x/crypto/poly1305", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - }, - { - "ImportPath": "golang.org/x/crypto/salsa20/salsa", - "Rev": "beef0f4390813b96e8e68fd78570396d0f4751fc" - } - ] -} diff --git a/Godeps/Readme b/Godeps/Readme deleted file mode 100644 index 4cdaa53..0000000 --- a/Godeps/Readme +++ /dev/null @@ -1,5 +0,0 @@ -This directory tree is generated automatically by godep. - -Please do not edit. - -See https://github.com/tools/godep for more information. diff --git a/README.md b/README.md index 567c82d..dc4a85b 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,103 @@ -#### Current test status +# `totp` -[![Build Status](https://travis-ci.org/sec51/twofactor.svg?branch=master)](https://travis-ci.org/sec51/twofactor.svg?branch=master) -[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/github.com/sec51/twofactor/) +This package implements the RFC 6238 OATH-TOTP algorithm -## `totp` +## Current Status -This package implements the RFC 6238 OATH-TOTP algorithm; +![Build](https://github.com/pilinux/twofactor/actions/workflows/go.yml/badge.svg) +![Linter](https://github.com/pilinux/twofactor/actions/workflows/golangci-lint.yml/badge.svg) +![CodeQL](https://github.com/pilinux/twofactor/actions/workflows/codeql.yml/badge.svg) +[![Go Report Card](https://goreportcard.com/badge/github.com/pilinux/twofactor)](https://goreportcard.com/report/github.com/pilinux/twofactor) +[![Go Reference](https://pkg.go.dev/badge/github.com/pilinux/twofactor.svg)](https://pkg.go.dev/github.com/pilinux/twofactor) -### Installation +- Forked from [sec51/twofactor](https://github.com/sec51/twofactor) +- Actively maintained, updates will be released under [pilinux/twofactor](https://github.com/pilinux/twofactor) -```go get github.com/sec51/twofactor``` +## Features -### Features +- Built-in support for secure crypto keys generation +- Built-in encryption of the secret keys when converted to bytes, so that they can be safely transmitted over the network, or stored in a DB +- Built-in back-off time when a user fails to authenticate more than 3 times +- Built-in serialization and deserialization to store the one time token struct in a persistence layer +- Automatic re-synchronization with the client device +- Built-in generation of a PNG QR Code for easily adding the secret key on the user device +- Supports 6, 7, 8 digits tokens +- Supports HMAC-SHA1, HMAC-SHA256, HMAC-SHA512 +- Generation of recovery tokens -* Built-in support for secure crypto keys generation - -* Built in encryption of the secret keys when converted to bytes, so that they can be safely transmitted over the network, or stored in a DB - -* Built-in back-off time when a user fails to authenticate more than 3 times - -* Bult-in serialization and deserialization to store the one time token struct in a persistence layer - -* Automatic re-synchronization with the client device - -* Built-in generation of a PNG QR Code for adding easily the secret key on the user device - -* Supports 6, 7, 8 digits tokens - -* Supports HMAC-SHA1, HMAC-SHA256, HMAC-SHA512 - - -### Storing Keys +## Storing Keys > The key is created using Golang crypto random function. It's a **secret key** and therefore > it needs to be **protected against unauthorized access**. The key cannot be leaked, otherwise the security is completely compromised. -> The key is presented to the user in a form of QR Code. Once scanned the key should never be revealed again. +> The key is presented to the user in a form of QR Code. Once scanned, the key should never be revealed again. > In addition when the QR code is shared with the client for scanning, the connection used must be secured (HTTPS). -The `totp` struct can be easily serialized using the `ToBytes()` function. -The bytes can then be stored on a persistent layer (database for example). The bytes are encrypted using `cryptoengine` library (NaCl) +The `totp` struct can be easily serialized using the `ToBytes()` function. +The bytes can then be stored on a persistence layer (database for example). The bytes are encrypted using `cryptoengine` library (NaCl) You can then retrieve the object back with the function: `TOTPFromBytes` > You can transfer the bytes securely via a network connection (Ex. if the database is in a different server) because they are encrypted and authenticated. -The struct needs to be stored in a persistent layer becase its values, like last token verification time, +The struct needs to be stored in a persistence layer because its values, like last token verification time, max user authentication failures, etc.. need to be preserved. -The secret key needs to be preserved too, between the user accound and the user device. +The secret key needs to be preserved too between the user account and the user device. The secret key is in fact used to derive tokens. -### Upcoming features - -* Generation of recovery tokens. - -* Integration with Twilio for sending the token via SMS, in case the user loses its entry in the Google authenticator app. - - -### Example Usages +## Example Usages -#### Case 1: Google Authenticator +### Case 1: Google Authenticator -* How to use the library +- How to use the library -1- Import the library + - 1. Import the library -``` -import github.com/sec51/twofactor -``` + ```go + import github.com/pilinux/twofactor + ``` -2- Instanciate the `totp` object via: + - 2. Instantiate the `totp` object via: -``` - otp, err := twofactor.NewTOTP("info@sec51.com", "Sec51", crypto.SHA1, 8) - if err != nil { - return err - } -``` + ```go + otp, err := twofactor.NewTOTP("info@sec51.com", "Sec51", crypto.SHA1, 8) + if err != nil { + return err + } + ``` -3- Display the PNG QR code to the user and an input text field, so that he can insert the token generated from his device + - 3. Display the PNG QR code to the user and an input text field, so that he can insert the token generated from his device -``` - qrBytes, err := otp.QR() - if err != nil { - return err - } -``` + ```go + qrBytes, err := otp.QR() + if err != nil { + return err + } + ``` -4- Verify the user provided token, coming from the google authenticator app - -``` - err := otp.Validate(USER_PROVIDED_TOKEN) - if err != nil { - return err - } - // if there is an error, then the authentication failed - // if it succeeded, then store this information and do not display the QR code ever again. -``` - -5- All following authentications should display only a input field with no QR code. + - 4. Verify the user-provided token generating by the google authenticator app + ```go + err := otp.Validate(USER_PROVIDED_TOKEN) + if err != nil { + return err + } + // if there is an error, then the authentication failed + // if it succeeded, then store this information and do not display the QR code ever again. + ``` -### References + - 5. All following authentications should display only a input field with no QR code. -* [RFC 6238 - *TOTP: Time-Based One-Time Password Algorithm*](https://tools.ietf.org/rfc/rfc6238.txt) +## References -* The [Key URI Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) +- [RFC 6238 - *TOTP: Time-Based One-Time Password Algorithm*](https://tools.ietf.org/rfc/rfc6238.txt) +- The [Key URI Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) - -### Author +## Author `totp` was written by Sec51 . +## License -### License - -``` +```LICENSE Copyright (c) 2015 Sec51.com Permission to use, copy, modify, and distribute this software for any diff --git a/doc.go b/doc.go index c2ef59d..e7ea8f5 100644 --- a/doc.go +++ b/doc.go @@ -1,3 +1,4 @@ +// Package twofactor - /* The package twofactor implements the RFC 6238 TOTP: Time-Based One-Time Password Algorithm diff --git a/glide.lock b/glide.lock deleted file mode 100644 index 850f031..0000000 --- a/glide.lock +++ /dev/null @@ -1,26 +0,0 @@ -hash: edc113943b5834aa52876ee0bdeac172678a94416ed1f3ed8da78afbff402d89 -updated: 2018-09-11T13:25:32.886071+02:00 -imports: -- name: github.com/sec51/convert - version: 3276ac712ca35cb9cc9a823b564fdaf89f4ac803 - subpackages: - - bigendian - - smallendian -- name: github.com/sec51/cryptoengine - version: 2306d105a49ec564d9d376570a1881d557fc4a82 -- name: github.com/sec51/gf256 - version: 2454accbeb9e6b0e2e53b01e1d641c7157251ed4 -- name: github.com/sec51/qrcode - version: b7779abbcaf1ec4de65f586a85fe24db31d45e7c - subpackages: - - coding -- name: golang.org/x/crypto - version: beef0f4390813b96e8e68fd78570396d0f4751fc - subpackages: - - curve25519 - - hkdf - - nacl/box - - nacl/secretbox - - poly1305 - - salsa20/salsa -testImports: [] diff --git a/glide.yaml b/glide.yaml deleted file mode 100644 index ba435ae..0000000 --- a/glide.yaml +++ /dev/null @@ -1,24 +0,0 @@ -package: github.com/sec51/twofactor -import: -- package: github.com/sec51/convert - version: 1.0.1 - subpackages: - - bigendian - - smallendian -- package: github.com/sec51/cryptoengine - version: 0.0.1 -- package: github.com/sec51/gf256 - version: 2454accbeb9e6b0e2e53b01e1d641c7157251ed4 -- package: github.com/sec51/qrcode - version: b7779abbcaf1ec4de65f586a85fe24db31d45e7c - subpackages: - - coding -- package: golang.org/x/crypto - version: beef0f4390813b96e8e68fd78570396d0f4751fc - subpackages: - - curve25519 - - hkdf - - nacl/box - - nacl/secretbox - - poly1305 - - salsa20/salsa diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c1c93bc --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/pilinux/twofactor + +go 1.24.0 + +require ( + github.com/pilinux/cryptoengine v0.1.13 + github.com/sec51/convert v1.0.2 + golang.org/x/crypto v0.46.0 + golang.org/x/sync v0.19.0 + rsc.io/qr v0.2.0 +) + +require golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a4ef06 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/pilinux/cryptoengine v0.1.13 h1:qA1ZFyh2LRlKEC+dnCsrKC0uygOX9h5mx9aV/pgB00A= +github.com/pilinux/cryptoengine v0.1.13/go.mod h1:kmat6J2lxQtqacgRINz2Xv47rWRe4L5AAKX4qGBrblU= +github.com/sec51/convert v1.0.2 h1:NoKWIRARjM3rQglNypMpcXSLLqPsN/uTTzaGeqDKbeg= +github.com/sec51/convert v1.0.2/go.mod h1:5qL/cT/oiOIvWXy2SccQ7LnacYftqqy9wdyFkTc1k2w= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/recover_test.go b/recover_test.go new file mode 100644 index 0000000..3ba5998 --- /dev/null +++ b/recover_test.go @@ -0,0 +1,137 @@ +package twofactor + +import ( + "regexp" + "strings" + "testing" +) + +// https://github.com/AtomicNibble/twofactor/blob/master/recover_test.go | commit: 2b0ae4f + +func TestGenerateRecoveryCodes(t *testing.T) { + t.Parallel() + + codes, err := GenerateRecoveryCodes() + if err != nil { + t.Fatal(err) + } + + if len(codes) != 10 { + t.Error("it should create 10 codes, got:", len(codes)) + } + + rgx := regexp.MustCompile(`^[0-9A-Z]{6}-[0-9A-Z]{6}$`) + for _, c := range codes { + if !rgx.MatchString(c) { + t.Errorf("code %s did not match regexp", c) + } + + if !ValidRecoveryCode(c) { + t.Errorf("code %s did not match format", c) + } + } +} + +func TestHashRecoveryCodes(t *testing.T) { + t.Parallel() + + codes, err := GenerateRecoveryCodes() + if err != nil { + t.Fatal(err) + } + + if len(codes) != 10 { + t.Error("it should create 10 codes, got:", len(codes)) + } + + cryptedCodes, err := BCryptRecoveryCodes(codes) + if err != nil { + t.Fatal(err) + } + + for _, c := range cryptedCodes { + if !strings.HasPrefix(c, "$2a$10$") { + t.Error("code did not look like bcrypt:", c) + } + } +} + +func TestUseRecoveryCode(t *testing.T) { + t.Parallel() + + codes, err := GenerateRecoveryCodes() + if err != nil { + t.Fatal(err) + } + + if len(codes) != 10 { + t.Error("it should create 10 codes, got:", len(codes)) + } + + cryptedCodes, err := BCryptRecoveryCodes(codes) + if err != nil { + t.Fatal(err) + } + + for _, c := range cryptedCodes { + if !strings.HasPrefix(c, "$2a$10$") { + t.Error("code did not look like bcrypt:", c) + } + } + + remaining, ok := UseRecoveryCode(cryptedCodes, codes[4]) + if !ok { + t.Error("should have used a code") + } + + if want, got := len(cryptedCodes)-1, len(remaining); want != got { + t.Error("want:", want, "got:", got) + } + + if cryptedCodes[4] == remaining[4] { + t.Error("it should have used number 4") + } + + remaining, ok = UseRecoveryCode(remaining, codes[0]) + if !ok { + t.Error("should have used a code") + } + + if want, got := len(cryptedCodes)-2, len(remaining); want != got { + t.Error("want:", want, "got:", got) + } + + if cryptedCodes[0] == remaining[0] { + t.Error("it should have used number 0") + } + + remaining, ok = UseRecoveryCode(remaining, codes[len(codes)-1]) + if !ok { + t.Error("should have used a code") + } + + if want, got := len(cryptedCodes)-3, len(remaining); want != got { + t.Error("want:", want, "got:", got) + } + + if cryptedCodes[len(cryptedCodes)-1] == remaining[len(remaining)-1] { + t.Error("it should have used number 0") + } +} + +func BenchmarkGenerateRecoveryCodes(b *testing.B) { + b.SetParallelism(1) + for i := 0; i < b.N; i++ { + codes, err := GenerateRecoveryCodes() + if err != nil { + b.Fatal(err) + } + + cryptedCodes, err := BCryptRecoveryCodes(codes) + if err != nil { + b.Fatal(err) + } + + _ = cryptedCodes + } +} diff --git a/recovery.go b/recovery.go new file mode 100644 index 0000000..ad83e46 --- /dev/null +++ b/recovery.go @@ -0,0 +1,121 @@ +package twofactor + +import ( + "context" + "crypto/rand" + "io" + "regexp" + "strings" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/sync/errgroup" +) + +// https://github.com/AtomicNibble/twofactor/blob/master/recovery.go | commit: 2b0ae4f + +const ( + alphabet = "ABCDEFGHIJKMNOPQRSTUVWXYZ0123456789" + recoveryCodeLength = 12 +) + +var ( + rgx = regexp.MustCompile(`^[0-9A-Z]{6}-[0-9A-Z]{6}$`) +) + +// ValidRecoveryCode returns true if the code matches recovery code format +func ValidRecoveryCode(code string) bool { + return rgx.MatchString(code) +} + +// GenerateRecoveryCodes creates 10 recovery codes of the form: +// +// abd34-1b24do (using alphabet, of length recoveryCodeLength). +func GenerateRecoveryCodes() ([]string, error) { + byt := make([]byte, 10*recoveryCodeLength) + if _, err := io.ReadFull(rand.Reader, byt); err != nil { + return nil, err + } + + codes := make([]string, 10) + for i := range codes { + builder := new(strings.Builder) + for j := 0; j < recoveryCodeLength; j++ { + if recoveryCodeLength/2 == j { + builder.WriteByte('-') + } + + randNumber := byt[i*recoveryCodeLength+j] % byte(len(alphabet)) + builder.WriteByte(alphabet[randNumber]) + } + codes[i] = builder.String() + } + + return codes, nil +} + +// BCryptRecoveryCodes hashes each recovery code given and return them in a new +// slice. +func BCryptRecoveryCodes(codes []string) ([]string, error) { + num := len(codes) + cryptedCodes := make([]string, num) + + g, _ := errgroup.WithContext(context.Background()) + + for i, c := range codes { + i, c := i, c // https://golang.org/doc/faq#closures_and_goroutines + g.Go(func() error { + + hash, err := bcrypt.GenerateFromPassword([]byte(c), bcrypt.DefaultCost) + if err != nil { + return err + } + + cryptedCodes[i] = string(hash) + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + return cryptedCodes, nil +} + +// UseRecoveryCode deletes the code that was used from the string slice and +// returns it, the bool is true if a code was used +func UseRecoveryCode(codes []string, inputCode string) ([]string, bool) { + input := []byte(inputCode) + use := -1 + + for i, c := range codes { + err := bcrypt.CompareHashAndPassword([]byte(c), input) + if err == nil { + use = i + break + } + } + + if use < 0 { + return nil, false + } + + ret := make([]string, len(codes)-1) + for j := range codes { + if j == use { + continue + } + set := j + if j > use { + set-- + } + ret[set] = codes[j] + } + + return ret, true +} + +// EncodeRecoveryCodes is an alias for strings.Join(",") +func EncodeRecoveryCodes(codes []string) string { return strings.Join(codes, ",") } + +// DecodeRecoveryCodes is an alias for strings.Split(",") +func DecodeRecoveryCodes(codes string) []string { return strings.Split(codes, ",") } diff --git a/totp.go b/totp.go index 712cd07..3aa2d0c 100644 --- a/totp.go +++ b/totp.go @@ -19,42 +19,47 @@ import ( "strconv" "time" + "github.com/pilinux/cryptoengine" "github.com/sec51/convert" "github.com/sec51/convert/bigendian" - "github.com/sec51/cryptoengine" - qr "github.com/sec51/qrcode" + "rsc.io/qr" ) const ( - backoff_minutes = 5 // this is the time to wait before verifying another token - max_failures = 3 // total amount of failures, after that the user needs to wait for the backoff time - counter_size = 8 // this is defined in the RFC 4226 - message_type = 0 // this is the message type for the crypto engine + backOffMinutes = 5 // this is the time to wait before verifying another token + maxFailures = 3 // total amount of failures, after that the user needs to wait for the backoff time + counterSize = 8 // this is defined in the RFC 4226 + messageType = 0 // this is the message type for the crypto engine ) var ( - initializationFailedError = errors.New("Totp has not been initialized correctly") - LockDownError = errors.New("The verification is locked down, because of too many trials.") + errInitializationFailed = fmt.Errorf("TOTP has not been initialized correctly") + errLockDown = fmt.Errorf("the verification is locked down, because of too many trials") + errTokenMismatch = fmt.Errorf("tokens mismatch") ) -// WARNING: The `Totp` struct should never be instantiated manually! +// Totp - WARNING: The `Totp` struct should never be instantiated manually! +// // Use the `NewTOTP` function type Totp struct { - key []byte // this is the secret key - counter [counter_size]byte // this is the counter used to synchronize with the client device - digits int // total amount of digits of the code displayed on the device - issuer string // the company which issues the 2FA - account string // usually the user email or the account id - stepSize int // by default 30 seconds - clientOffset int // the amount of steps the client is off - totalVerificationFailures int // the total amount of verification failures from the client - by default 10 - lastVerificationTime time.Time // the last verification executed - hashFunction crypto.Hash // the hash function used in the HMAC construction (sha1 - sha156 - sha512) + key []byte // this is the secret key + counter [counterSize]byte // this is the counter used to synchronize with the client device + digits int // total amount of digits of the code displayed on the device + issuer string // the company which issues the 2FA + account string // usually the user email or the account id + stepSize int // by default 30 seconds + clientOffset int // the amount of steps the client is off + totalVerificationFailures int // the total amount of verification failures from the client - by default 10 + lastVerificationTime time.Time // the last verification executed + hashFunction crypto.Hash // the hash function used in the HMAC construction (sha1 - sha156 - sha512) } // This function is used to synchronize the counter with the client -// Offset can be a negative number as well -// Usually it's either -1, 0 or 1 +// +// # Offset can be a negative number as well +// +// # Usually it's either -1, 0 or 1 +// // This is used internally func (otp *Totp) synchronizeCounter(offset int) { otp.clientOffset = offset @@ -70,23 +75,30 @@ func (otp *Totp) getIntCounter() uint64 { return bigendian.FromUint64(otp.counter) } -// This function creates a new TOTP object -// This is the function which is needed to start the whole process +// NewTOTP - This function creates a new TOTP object +// +// # This is the function which is needed to start the whole process +// // account: usually the user email +// // issuer: the name of the company/service +// // hash: is the crypto function used: crypto.SHA1, crypto.SHA256, crypto.SHA512 +// // digits: is the token amount of digits (6 or 7 or 8) +// // steps: the amount of second the token is valid +// // it automatically generates a secret key using the golang crypto rand package. If there is not enough entropy the function returns an error +// // The key is not encrypted in this package. It's a secret key. Therefore if you transfer the key bytes in the network, // please take care of protecting the key or in fact all the bytes. func NewTOTP(account, issuer string, hash crypto.Hash, digits int) (*Totp, error) { - keySize := hash.Size() key := make([]byte, keySize) total, err := rand.Read(key) if err != nil { - return nil, errors.New(fmt.Sprintf("TOTP failed to create because there is not enough entropy, we got only %d random bytes", total)) + return nil, fmt.Errorf("TOTP failed to create because there is not enough entropy, we got only %d random bytes", total) } // sanitize the digits range otherwise it may create invalid tokens ! @@ -95,10 +107,10 @@ func NewTOTP(account, issuer string, hash crypto.Hash, digits int) (*Totp, error } return makeTOTP(key, account, issuer, hash, digits) - } // Private function which initialize the TOTP so that it's easier to unit test it +// // Used internally func makeTOTP(key []byte, account, issuer string, hash crypto.Hash, digits int) (*Totp, error) { otp := new(Totp) @@ -112,17 +124,22 @@ func makeTOTP(key []byte, account, issuer string, hash crypto.Hash, digits int) return otp, nil } -// This function validates the user provided token +// Validate - This function validates the user provided token +// // It calculates 3 different tokens. The current one, one before now and one after now. +// // The difference is driven by the TOTP step size -// Based on which of the 3 steps it succeeds to validates, the client offset is updated. -// It also updates the total amount of verification failures and the last time a verification happened in UTC time -// Returns an error in case of verification failure, with the reason -// There is a very basic method which protects from timing attacks, although if the step time used is low it should not be necessary +// based on which of the 3 steps it succeeds to validates, the client offset is updated. +// +// It also updates the total amount of verification failures and the last time a verification happened in UTC time. +// +// Returns an error in case of verification failure, with the reason. +// +// There is a very basic method which protects from timing attacks, although if the step time used is low it should not be necessary. +// // An attacker can still learn the synchronization offset. This is however irrelevant because the attacker has then 30 seconds to -// guess the code and after 3 failures the function returns an error for the following 5 minutes +// guess the code and after 3 failures the function returns an error for the following 5 minutes. func (otp *Totp) Validate(userCode string) error { - // check Totp initialization if err := totpHasBeenInitialized(otp); err != nil { return err @@ -130,15 +147,16 @@ func (otp *Totp) Validate(userCode string) error { // verify that the token is valid if userCode == "" { - return errors.New("User provided token is empty") + return errors.New("user-provided token is empty") } // check against the total amount of failures - if otp.totalVerificationFailures >= max_failures && !validBackoffTime(otp.lastVerificationTime) { - return LockDownError - } + if otp.totalVerificationFailures >= maxFailures { + + if !validBackOffTime(otp.lastVerificationTime) { + return errLockDown + } - if otp.totalVerificationFailures >= max_failures && validBackoffTime(otp.lastVerificationTime) { // reset the total verification failures counter otp.totalVerificationFailures = 0 } @@ -178,21 +196,23 @@ func (otp *Totp) Validate(userCode string) error { otp.lastVerificationTime = time.Now().UTC() // important to have it in UTC // if we got here everything is good - return errors.New("Tokens mismatch.") + return errTokenMismatch } -// Checks the time difference between the function call time and the parameter -// if the difference of time is greater than BACKOFF_MINUTES it returns true, otherwise false -func validBackoffTime(lastVerification time.Time) bool { - diff := lastVerification.UTC().Add(backoff_minutes * time.Minute) +// Checks the time difference between the function call time and the parameter. +// If the difference of time is greater than BACKOFF_MINUTES it returns true, otherwise false. +func validBackOffTime(lastVerification time.Time) bool { + diff := lastVerification.UTC().Add(backOffMinutes * time.Minute) return time.Now().UTC().After(diff) } // Basically, we define TOTP as TOTP = HOTP(K, T), where T is an integer // and represents the number of time steps between the initial counter // time T0 and the current Unix time. +// // T = (Current Unix time - T0) / X, where the // default floor function is used in the computation. +// // For example, with T0 = 0 and Time Step X = 30, T = 1 if the current // Unix time is 59 seconds, and T = 2 if the current Unix time is // 60 seconds. @@ -210,9 +230,8 @@ func increment(ts int64, stepSize int) uint64 { return n // convert n to little endian byte array } -// Generates a new one time password with hmac-(HASH-FUNCTION) +// OTP Generates a new one time password with hmac-(HASH-FUNCTION) func (otp *Totp) OTP() (string, error) { - // verify the proper initialization if err := totpHasBeenInitialized(otp); err != nil { return "", err @@ -223,6 +242,7 @@ func (otp *Totp) OTP() (string, error) { } // Private function which calculates the OTP token based on the index offset +// // example: 1 * steps or -1 * steps func calculateTOTP(otp *Totp, index int) string { var h hash.Hash @@ -230,14 +250,10 @@ func calculateTOTP(otp *Totp, index int) string { switch otp.hashFunction { case crypto.SHA256: h = hmac.New(sha256.New, otp.key) - break case crypto.SHA512: h = hmac.New(sha512.New, otp.key) - break default: h = hmac.New(sha1.New, otp.key) - break - } // set the counter to the current step based ont the current time @@ -245,21 +261,19 @@ func calculateTOTP(otp *Totp, index int) string { otp.incrementCounter(index) return calculateToken(otp.counter[:], otp.digits, h) - } -func truncateHash(hmac_result []byte, size int) int64 { - offset := hmac_result[size-1] & 0xf - bin_code := (uint32(hmac_result[offset])&0x7f)<<24 | - (uint32(hmac_result[offset+1])&0xff)<<16 | - (uint32(hmac_result[offset+2])&0xff)<<8 | - (uint32(hmac_result[offset+3]) & 0xff) - return int64(bin_code) +func truncateHash(hmacResult []byte, size int) int64 { + offset := hmacResult[size-1] & 0xf + binCode := (uint32(hmacResult[offset])&0x7f)<<24 | + (uint32(hmacResult[offset+1])&0xff)<<16 | + (uint32(hmacResult[offset+2])&0xff)<<8 | + (uint32(hmacResult[offset+3]) & 0xff) + return int64(binCode) } // this is the function which calculates the HTOP code func calculateToken(counter []byte, digits int, h hash.Hash) string { - h.Write(counter) hashResult := h.Sum(nil) result := truncateHash(hashResult, h.Size()) @@ -279,16 +293,26 @@ func (otp *Totp) Secret() string { return base32.StdEncoding.EncodeToString(otp.key) } +// HashFunction returns the hash function used +func (otp *Totp) HashFunction() crypto.Hash { + return otp.hashFunction +} + +// NumDigits returns total amount of digits of the code displayed on the device +func (otp *Totp) NumDigits() int { + return otp.digits +} + // URL returns a suitable URL, such as for the Google Authenticator app +// // example: otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example -func (otp *Totp) url() (string, error) { - +func (otp *Totp) URL() (string, error) { // verify the proper initialization if err := totpHasBeenInitialized(otp); err != nil { return "", err } - secret := base32.StdEncoding.EncodeToString(otp.key) + secret := otp.Secret() u := url.URL{} v := url.Values{} u.Scheme = "otpauth" @@ -302,27 +326,23 @@ func (otp *Totp) url() (string, error) { switch otp.hashFunction { case crypto.SHA256: v.Add("algorithm", "SHA256") - break case crypto.SHA512: v.Add("algorithm", "SHA512") - break default: v.Add("algorithm", "SHA1") - break } u.RawQuery = v.Encode() return u.String(), nil } // QR generates a byte array containing QR code encoded PNG image, with level Q error correction, -// needed for the client apps to generate tokens +// needed for the client apps to generate tokens. // The QR code should be displayed only the first time the user enabled the Two-Factor authentication. // The QR code contains the shared KEY between the server application and the client application, // therefore the QR code should be delivered via secure connection. func (otp *Totp) QR() ([]byte, error) { - // get the URL - u, err := otp.url() + u, err := otp.URL() // check for errors during initialization // this is already done on the URL method @@ -337,11 +357,17 @@ func (otp *Totp) QR() ([]byte, error) { } // ToBytes serialises a TOTP object in a byte array +// // Sizes: 4 4 N 8 4 4 N 4 N 4 4 4 8 4 +// // Format: |total_bytes|key_size|key|counter|digits|issuer_size|issuer|account_size|account|steps|offset|total_failures|verification_time|hashFunction_type| +// // hashFunction_type: 0 = SHA1; 1 = SHA256; 2 = SHA512 +// // The data is encrypted using the cryptoengine library (which is a wrapper around the golang NaCl library) +// // TODO: +// // 1- improve sizes. For instance the hashFunction_type could be a short. func (otp *Totp) ToBytes() ([]byte, error) { @@ -440,13 +466,11 @@ func (otp *Totp) ToBytes() ([]byte, error) { if _, err := buffer.Write(sha256Bytes[:]); err != nil { return nil, err } - break case crypto.SHA512: sha512Bytes := bigendian.ToInt(2) if _, err := buffer.Write(sha512Bytes[:]); err != nil { return nil, err } - break default: sha1Bytes := bigendian.ToInt(0) if _, err := buffer.Write(sha1Bytes[:]); err != nil { @@ -461,7 +485,7 @@ func (otp *Totp) ToBytes() ([]byte, error) { } // init the message to be encrypted - message, err := cryptoengine.NewMessage(buffer.String(), message_type) + message, err := cryptoengine.NewMessage(buffer.String(), messageType) if err != nil { return nil, err } @@ -473,14 +497,12 @@ func (otp *Totp) ToBytes() ([]byte, error) { } return encryptedMessage.ToBytes() - } -// TOTPFromBytes converts a byte array to a totp object -// it stores the state of the TOTP object, like the key, the current counter, the client offset, -// the total amount of verification failures and the last time a verification happened +// TOTPFromBytes converts a byte array to a totp object. +// It stores the state of the TOTP object, like the key, the current counter, the client offset, +// the total amount of verification failures and the last time a verification happened. func TOTPFromBytes(encryptedMessage []byte, issuer string) (*Totp, error) { - // init the cryptoengine engine, err := cryptoengine.InitCryptoEngine(issuer) if err != nil { @@ -535,7 +557,7 @@ func TOTPFromBytes(encryptedMessage []byte, issuer string) (*Totp, error) { startOffset = endOffset endOffset = startOffset + 4 b = buffer[startOffset:endOffset] - otp.digits = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]}) // + otp.digits = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]}) // read the issuer size startOffset = endOffset @@ -593,10 +615,8 @@ func TOTPFromBytes(encryptedMessage []byte, issuer string) (*Totp, error) { switch hashType { case 1: otp.hashFunction = crypto.SHA256 - break case 2: otp.hashFunction = crypto.SHA512 - break default: otp.hashFunction = crypto.SHA1 } @@ -607,7 +627,7 @@ func TOTPFromBytes(encryptedMessage []byte, issuer string) (*Totp, error) { // this method checks the proper initialization of the Totp object func totpHasBeenInitialized(otp *Totp) error { if otp == nil || otp.key == nil || len(otp.key) == 0 { - return initializationFailedError + return errInitializationFailed } return nil } diff --git a/totp_test.go b/totp_test.go index 4cf621d..2d8527a 100644 --- a/totp_test.go +++ b/totp_test.go @@ -63,7 +63,6 @@ func checkError(t *testing.T, err error) { } func TestTOTP(t *testing.T) { - keySha1, err := hex.DecodeString(sha1KeyHex) checkError(t, err) @@ -117,11 +116,9 @@ func TestTOTP(t *testing.T) { t.Errorf("SHA512 test data, token mismatch. Got %s, expected %s\n", token, expected) } } - } func TestVerificationFailures(t *testing.T) { - otp, err := NewTOTP("info@sec51.com", "Sec51", crypto.SHA1, 7) //checkError(t, err) if err != nil { @@ -147,7 +144,7 @@ func TestVerificationFailures(t *testing.T) { } if otp.totalVerificationFailures != 3 { - t.Errorf("Expected 3 verification failures, instead we've got %d\n", otp.totalVerificationFailures) + t.Errorf("expected 3 verification failures, instead we've got %d\n", otp.totalVerificationFailures) } // at this point we crossed the max failures, therefore it should always return an error @@ -158,8 +155,8 @@ func TestVerificationFailures(t *testing.T) { } // test the validBackoffTime function - if validBackoffTime(otp.lastVerificationTime) { - t.Error("validBackoffTime should return false") + if validBackOffTime(otp.lastVerificationTime) { + t.Error("validBackOffTime should return false") } // serialize and deserialize the object and verify again @@ -175,11 +172,11 @@ func TestVerificationFailures(t *testing.T) { // make sure the fields are the same after parsing the token from bytes if otp.label() != restoredOtp.label() { - t.Error("Label mismatch between in memory OTP and byte parsed OTP") + t.Error("label mismatch between in memory OTP and byte parsed OTP") } // test the validBackoffTime function - if validBackoffTime(restoredOtp.lastVerificationTime) { + if validBackOffTime(restoredOtp.lastVerificationTime) { t.Error("validBackoffTime should return false") } @@ -189,7 +186,7 @@ func TestVerificationFailures(t *testing.T) { otp.lastVerificationTime = time.Now().UTC().Add(back10Minutes) // test the validBackoffTime function - if !validBackoffTime(otp.lastVerificationTime) { + if !validBackOffTime(otp.lastVerificationTime) { t.Error("validBackoffTime should return true") } @@ -205,11 +202,9 @@ func TestVerificationFailures(t *testing.T) { } } } - } func TestIncrementCounter(t *testing.T) { - ts := int64(1438601387) unixTime := time.Unix(ts, 0).UTC() // DEBUG @@ -217,9 +212,8 @@ func TestIncrementCounter(t *testing.T) { result := increment(unixTime.Unix(), 30) expected := uint64(47953379) if result != expected { - t.Fatal("Error incrementing counter") + t.Fatal("error incrementing counter") } - } func TestSerialization(t *testing.T) { @@ -253,43 +247,44 @@ func TestSerialization(t *testing.T) { } if deserializedOTP == nil { - t.Error("Could not deserialize back the TOTP object from bytes") + t.Error("could not deserialize back the TOTP object from bytes") + return } - if bytes.Compare(deserializedOTP.key, otp.key) != 0 { - t.Error("Deserialized digits property differ from original TOTP") + if !bytes.Equal(deserializedOTP.key, otp.key) { + t.Error("deserialized digits property differ from original TOTP") } if deserializedOTP.digits != otp.digits { - t.Error("Deserialized digits property differ from original TOTP") + t.Error("deserialized digits property differ from original TOTP") } if deserializedOTP.totalVerificationFailures != otp.totalVerificationFailures { - t.Error("Deserialized totalVerificationFailures property differ from original TOTP") + t.Error("deserialized totalVerificationFailures property differ from original TOTP") } if deserializedOTP.stepSize != otp.stepSize { - t.Error("Deserialized stepSize property differ from original TOTP") + t.Error("deserialized stepSize property differ from original TOTP") } if deserializedOTP.lastVerificationTime.Unix() != otp.lastVerificationTime.Unix() { - t.Error("Deserialized lastVerificationTime property differ from original TOTP") + t.Error("deserialized lastVerificationTime property differ from original TOTP") } if deserializedOTP.getIntCounter() != otp.getIntCounter() { - t.Error("Deserialized counter property differ from original TOTP") + t.Error("deserialized counter property differ from original TOTP") } if deserializedOTP.clientOffset != otp.clientOffset { - t.Error("Deserialized clientOffset property differ from original TOTP") + t.Error("deserialized clientOffset property differ from original TOTP") } if deserializedOTP.account != otp.account { - t.Error("Deserialized account property differ from original TOTP") + t.Error("deserialized account property differ from original TOTP") } if deserializedOTP.issuer != otp.issuer { - t.Error("Deserialized issuer property differ from original TOTP") + t.Error("deserialized issuer property differ from original TOTP") } deserializedToken, err := deserializedOTP.OTP() @@ -301,32 +296,32 @@ func TestSerialization(t *testing.T) { t.Error(err) } if deserializedToken != token { - t.Error("Deserialized OTP token property differ from original TOTP") + t.Error("deserialized OTP token property differ from original TOTP") } if deserializedOTP.hashFunction != otp.hashFunction { - t.Error("Deserialized hash property differ from original TOTP") + t.Error("deserialized hash property differ from original TOTP") } - deserializedUrl, err := deserializedOTP.url() + deserializedURL, err := deserializedOTP.URL() if err != nil { t.Error(err) } - otpdUrl, err := otp.url() + otpdURL, err := otp.URL() if err != nil { t.Error(err) } - if deserializedUrl != otpdUrl { - t.Error("Deserialized URL property differ from original TOTP") + if deserializedURL != otpdURL { + t.Error("deserialized url property differ from original TOTP") } if deserializedOTP.label() != otp.label() { - t.Error("Deserialized Label property differ from original TOTP") + t.Error("deserialized label property differ from original TOTP") } if base64.StdEncoding.EncodeToString(otpData) != base64.StdEncoding.EncodeToString(deserializedOTPData) { - t.Error("Problems encoding TOTP to base64") + t.Error("problems encoding TOTP to base64") } label, err := url.QueryUnescape(otp.label()) @@ -335,20 +330,18 @@ func TestSerialization(t *testing.T) { } if label != "Sec51:info@sec51.com" { - t.Error("Creation of TOTP Label failed") + t.Error("creation of TOTP Label failed") } - } func TestProperInitialization(t *testing.T) { otp := Totp{} - if _, err := otp.url(); err == nil { - t.Fatal("Totp is not properly initialized and the method did not catch it") + if _, err := otp.URL(); err == nil { + t.Fatal("TOTP is not properly initialized and the method did not catch it") } } func TestCounterSynchronization(t *testing.T) { - // create totp otp, err := NewTOTP("info@sec51.com", "Sec51", crypto.SHA512, 8) if err != nil { @@ -360,7 +353,7 @@ func TestCounterSynchronization(t *testing.T) { t.Fatal(err) } - token_1 := calculateTOTP(otp, -1) + tokenNegative1 := calculateTOTP(otp, -1) if err != nil { t.Fatal(err) } @@ -376,16 +369,16 @@ func TestCounterSynchronization(t *testing.T) { } // check the values if otp.clientOffset != 0 { - t.Errorf("Client offset should be 0, instead we've got %d\n", otp.clientOffset) + t.Errorf("client offset should be 0, instead we've got %d\n", otp.clientOffset) } - err = otp.Validate(token_1) + err = otp.Validate(tokenNegative1) if err != nil { t.Error(err) } // check the values if otp.clientOffset != -1 { - t.Errorf("Client offset should be -1, instead we've got %d\n", otp.clientOffset) + t.Errorf("client offset should be -1, instead we've got %d\n", otp.clientOffset) } err = otp.Validate(token1) @@ -394,7 +387,6 @@ func TestCounterSynchronization(t *testing.T) { } // check the values if otp.clientOffset != 1 { - t.Errorf("Client offset should be 0, instead we've got %d\n", otp.clientOffset) + t.Errorf("client offset should be 0, instead we've got %d\n", otp.clientOffset) } - }