Coder Social home page Coder Social logo

keinos / go-totp Goto Github PK

View Code? Open in Web Editor NEW
11.0 1.0 0.0 113 KB

Simple Go package to implement TOTP authentication functionality to the Go app. It supports ECDH as well.

License: MIT License

Go 100.00%
go golang golang-package one-time-password totp totp-generator ecdh

go-totp's Introduction

go-totp

go1.22+ Go Reference

go-totp is a simple Go package to implement Timebased-One-Time-Password authentication functionality, a.k.a. TOTP, to the Go app.

Note: This is a wrapper of the awesome github.com/pquerna/otp package to facilitate the use of TOTP.

As an optional feature, this package supports ECDH (Elliptic-Curve Diffie-Hellman) key agreement protocol, where public keys are exchanged between two parties to obtain a common TOTP passcode.

Usage

// Install package
go get "github.com/KEINOS/go-totp"

Basic Usage

// import "github.com/KEINOS/go-totp/totp"

func Example() {
    Issuer := "Example.com"            // name of the service
    AccountName := "[email protected]" // name of the user

    // Generate a new secret key with default options.
    // Compatible with most TOTP authenticator apps.
    key, err := totp.GenerateKey(Issuer, AccountName)
    if err != nil {
        log.Fatal(err)
    }

    // Print the default option values.
    fmt.Println("- Algorithm:", key.Options.Algorithm)
    fmt.Println("- Period:", key.Options.Period)
    fmt.Println("- Secret Size:", key.Options.SecretSize)
    fmt.Println("- Skew (time tolerance):", key.Options.Skew)
    fmt.Println("- Digits:", key.Options.Digits)

    // Generate 6 digits passcode (valid for 30 seconds)
    passcode, err := key.PassCode()
    if err != nil {
        log.Fatal(err)
    }

    // Validate the passcode
    if key.Validate(passcode) {
        fmt.Println("* Validation result: Passcode is valid")
    }
    //
    // Output:
    // - Algorithm: SHA1
    // - Period: 30
    // - Secret Size: 128
    // - Skew (time tolerance): 1
    // - Digits: 6
    // * Validation result: Passcode is valid
}
// ----------------------------------------------------------------------------
//  Generate a new secret key with custom options
// ----------------------------------------------------------------------------
key, err := totp.GenerateKey(Issuer, AccountName,
    totp.WithAlgorithm(totp.Algorithm("SHA256")),
    totp.WithPeriod(15),
    totp.WithSecretSize(256),
    totp.WithSkew(5),
    totp.WithDigits(totp.DigitsEight),
)

// ----------------------------------------------------------------------------
//  Major methods of totp.Key object
// ----------------------------------------------------------------------------
//  * You should handle the error in your code.

// Generate the current passcode.
//
// Which is a string of 8 digit numbers and valid for with 5 skews as set in
// the above options (5 skew = time tolerance of ± 15 seconds(period) * 5).
passcode, err := key.PassCode()

// Validate the received passcode.
ok := key.Validate(passcode)

// Get 100x100 px image of QR code as PNG byte data.
//
// FixLevelDefault is the 15% of error correction.
qrCodeObj, err := key.QRCode(totp.FixLevelDefault)
pngBytes, err := qrCodeObj.PNG(100, 100)

// Get the secret key in PEM format text.
pemKey, err := key.PEM()

// Get the secret key in TOTP URI format string.
// This is equivalent to key.String().
uriKey := key.URI()

// Retrieve the secret value in various formats.
// ---------------------------------------------

// Get the secret value in Base32 format string.
// This encoding is used in TOTP URI format and is equivalent to
// key.Secret.String().
base32Key := key.Secret.Base32()

// Get the secret value in Base62 format string.
base62Key := key.Secret.Base62()

// Get the secret value in Base64 format string.
// This encoding is used in PEM format.
base64Key := key.Secret.Base64()

// Get the secret value in bytes. This is the raw secret value.
rawKey := key.Secret.Bytes()

ECDH Support

This package supports ECDH key agreement protocol for the TOTP secret key generation (deriving TOTP secret from ECDH shared secret).

// Pre-agreement between Alice and Bob.
commonCurve := ecdh.X25519()
commonCtx := "example.com [email protected] [email protected] TOTP secret v1"

// Key exchange between Alice and Bob.
alicePriv, alicePub := getECDHKeysSomeHowForAlice(commonCurve)
bobPriv, bobPub := getECDHKeysSomeHowForBob(commonCurve)

// Generate a new TOTP key for Alice using:
// - Alice's ECDH private key
// - Bob's ECDH public key
// - Alice and Bob's common context string
Issuer := "Example.com"
AccountName := "[email protected]"

key, err := totp.GenerateKey(Issuer, AccountName,
    totp.WithECDH(alicePriv, bobPub, commonCtx),
)
if err != nil {
    log.Fatal(err)
}

// Alice generates 6 digits of TOTP passcode which should be the same as Bob's.
passcode, err := key.PassCode()
if err != nil {
    log.Fatal(err)
}

A shared secret key can be created by exchanging a public ECDH key between two parties. This shared secret key is used to derive the TOTP key. Thus the same TOTP passcode can be shared within the same time period.

This feature is useful when a shared but ephemeral/volatile secret value (a common TOTP passcode) is required to increase security between two parties.

For example, a time-based shared salt for hashing or an additional value to generate a shared secret key for symmetric encryption.

The values expire, but the possibilities are endless.

Contributing

go1.22+ Go Reference Opened Issues PR

Any Pull-Request for improvement is welcome!

Test Statuses

UnitTests PlatformTests

golangci-lint CodeQL-Analysis Vulnerability Scan

codecov Go Report Card

License, copyright and credits

go-totp's People

Contributors

dependabot[bot] avatar keinos avatar mergify[bot] avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

go-totp's Issues

bump up Go 1.18 to 1.22

Linter copyloopvar and intrange requires Go 1.22 or higher.

$ golangci-lint run
WARN [linters_context] copyloopvar: this linter is disabled because the Go version (1.18) of your project is lower than Go 1.22 
WARN [linters_context] intrange: this linter is disabled because the Go version (1.18) of your project is lower than Go 1.22 

Upgrading to version 1.22 is a good idea. At the moment, there is no reason to stick with older versions of Go.

Set skew to 1 by default and create a test

In our usual case, most of the time the code validation works well.

But, due to the default skew value being as 0, code validation often fails if the period are short.

Here's an example to reproduce. This test will pass if the skew is set to 1.

func TestKey_skew_as_one(t *testing.T) {
	// Backup and defer restore
	oldTimeNow := timeNow
	defer func() {
		timeNow = oldTimeNow
	}()

	key, err := GenerateKey("dummy issuer", "dummy account")
	require.NoError(t, err, "failed to generate TOTP key during test setup")

	key.Options.Period = 3 // 3 seconds
	//key.Options.Skew = 1   // ± 1 period of tolerance

	getCode := func(t *testing.T, key *Key) string {
		t.Helper()

		// Monkey patch timeNow
		timeNow = func() time.Time {
			return time.Now()
		}

		passCode, err := key.PassCode()
		require.NoError(t, err, "failed to generate passcode")

		return passCode
	}

	validateCode := func(t *testing.T, key *Key, passCode string) bool {
		t.Helper()

		// Monkey patch timeNow
		timeNow = func() time.Time {
			return time.Now().Add(time.Second * 200)
		}

		// sleep for 2 sec. This delay causes error/invalid 60% of the
		// time.
		time.Sleep(time.Second * 2)

		return key.Validate(passCode)
	}

	numValid := 0
	numIterations := 10

	for i := 0; i < numIterations; i++ {
		passCode := getCode(t, key)
		ok := validateCode(t, key, passCode)

		if ok {
			numValid++
		}
	}

	expect := numIterations
	actual := numValid

	require.Equal(t, expect, actual,
		"not all generated passcodes are valid")
}
$ go test -timeout 30s -run ^TestKey_skew_as_one$ github.com/KEINOS/go-totp/totp

--- FAIL: TestKey_skew_as_one (20.01s)
    /Users/admin/Library/Mobile Documents/com~apple~CloudDocs/Data_Git/GitHub/PublicRepos/go-totp/totp/issue_44_test.go:64: 
        	Error Trace:	/Users/admin/Library/Mobile Documents/com~apple~CloudDocs/Data_Git/GitHub/PublicRepos/go-totp/totp/issue_44_test.go:64
        	Error:      	Not equal: 
        	            	expected: 10
        	            	actual  : 4
        	Test:       	TestKey_skew_as_one
        	Messages:   	not all generated passcodes are valid
FAIL
FAIL	github.com/KEINOS/go-totp/totp	20.250s
FAIL

Let `Algorithm.ID` return `-1` rather than `0` in case of unsupported

Currently in v0.0.1, Algorithm.ID() method returns 0(the default algorithm) in case the underling algorithm is unsupported.

default:
return int(otp.AlgorithmSHA1)

Even though the constructor function NewAlgorithmStr or NewAlgorithmID can detect during instantiation, creating an object directly may cause an unknown behavior.

However, returning an error is a bit redundant. Therefore, -1 should be returned for types not supported by the algorithm.

Feature: Functional Option Pattern for `GenerateKey`

By its very nature, the fields of a private key object must not be allowed to change after instantiation.

Though, for user-defined keys, the field is currently set directly as such:

opts := totp.Options{
Issuer: "Example.com",
AccountName: "[email protected]",
Algorithm: totp.Algorithm("SHA1"), // Choices are: MD5, SHA1, SHA256 and SHA512
Period: 60, // Validity period in seconds
SecretSize: 20, // Secret key size in bytes
Skew: 0, // Number of periods before or after the current time to allow.
Digits: totp.Digits(8), // Choices are: 6 and 8
}

The problem here is that the fields are exposed to public.

To flexibly define fields during instantiation, we should use the Function Option Pattern and set the fields as private.

 Issuer := "Example.com"
 AccountName := "[email protected]"

- key, err := totp.GenerateKey(Issuer, AccountName)
+ key, err := totp.GenerateKey(
+     totp.WithIssuer(Issuer),
+     totp.WithAccountName(AccountName),
+ )

We decided to keep backward compatibility:

totp.GenerateKeyPEM --> totp.GenKeyFromPEM

Although totp.GenerateKeyPEM can be read to generate a PEM file, it is confusing because it actually generates an object from the PEM file.

- func GenerateKeyPEM(pemKey string) (*Key, error) {
+ func GenKeyFromPEM(pemKey string) (*Key, error) {

Feature: add additional fields for PEM output

Currently, exporting the generated Key object in PEM format supports the least fields by default.

For the future usage (such as ECDH support, #37), PEM key may require additional fields. Context or protocol versions for example.

- func (k *Key) PEM() (string, error)
+ func (k *Key) PEM[T string|int](optField ...[string]T) (string, error)

Feature: generate TOTP secret key from shared secret using ECDH

ECDH can establish a shared secret between two asymmetric key pairs. (key agreement.)

We can use this ECDH shared secret as a secret key of TOTP to obtain common time-based ephemeral value between two parties.

A value that can be used as; a common SALT for hashing, cryptographic key for symmetric-key encryption and etc.

Here's a working pseudo code:

package main

import (
	"fmt"
	"log"

	"crypto/ecdh"
	"crypto/rand"

	"github.com/KEINOS/go-totp/totp"
	"github.com/zeebo/blake3"
)

func main() {
	// -----------------------------------------------------------------------------
	//  Generate ECDH key pair using curve25519 (X25519)
	// -----------------------------------------------------------------------------

	// Agreed curve to use between Alice and Bob
	paramCommon := ecdh.X25519()

	// Alice ECDH key pair
	alicePrivKey, alicePubKey, err := newECDH(paramCommon)
	panicOnErr(err)

	// Bob ECDH key pair
	bobPrivKey, bobPubKey, err := newECDH(paramCommon)
	panicOnErr(err)

	fmt.Printf("Alice Priv: %x\n", alicePrivKey.Bytes())
	fmt.Printf("Alice Pub : %x\n", alicePubKey.Bytes())
	fmt.Printf("Bob Priv  : %x\n", bobPrivKey.Bytes())
	fmt.Printf("Bob Pub   : %x\n", bobPubKey.Bytes())

	// -----------------------------------------------------------------------------
	//  Calculate shared secret
	// -----------------------------------------------------------------------------
	// (we assume that public keys were safely exchanged/retrieved and trustworthy)

	// Alice shared secret
	aliceSecret, err := alicePrivKey.ECDH(bobPubKey)
	panicOnErr(err)

	// Bob shared secret
	bobSecret, err := bobPrivKey.ECDH(alicePubKey)
	panicOnErr(err)

	fmt.Printf("Alice's Shared Secret: %x (len: %v)\n", aliceSecret, len(aliceSecret))
	fmt.Printf("Bob's Shared Secret  : %x (len: %v)\n", bobSecret, len(bobSecret))

	// -----------------------------------------------------------------------------
	//  Hash the ECDH shared secret to 128 byte length using constant context as
	//  salt and use it as the secret of TOTP key
	// -----------------------------------------------------------------------------
	const ctxSalt = "example.com shared TOTP secret v1"

	// Alice TOTP secret key
	aliceTOTPSec := hash1024(aliceSecret, ctxSalt)

	// Bob TOTP secret key
	bobTOTPSec := hash1024(aliceSecret, ctxSalt)

	fmt.Printf("Alice TOTP sec key: %x...%x (len: %v)\n", aliceTOTPSec[:16], aliceTOTPSec[112:], len(aliceTOTPSec))
	fmt.Printf("Bob TOTP sec key  : %x...%x (len: %v)\n", bobTOTPSec[:16], bobTOTPSec[112:], len(bobTOTPSec))

	// -----------------------------------------------------------------------------
	//  Instanticate totp.Key object using the converted TOTP secret key above
	// -----------------------------------------------------------------------------

	// Alice TOTP key
	aliceKey, err := newTOTP("Example.com", "[email protected]", aliceTOTPSec)
	panicOnErr(err)

	// Bob TOTP key
	bobKey, err := newTOTP("Example.com", "[email protected]", bobTOTPSec)
	panicOnErr(err)

	// -----------------------------------------------------------------------------
	//  Generate TOTP pass code
	// -----------------------------------------------------------------------------

	// Alice generates 6 digits passcode (valid for 30 seconds)
	alicePass, err := aliceKey.PassCode()
	panicOnErr(err)

	// Bob generates 6 digits passcode (valid for 30 seconds)
	bobPass, err := bobKey.PassCode()
	panicOnErr(err)

	fmt.Println("Alice pass:", alicePass)
	fmt.Println("Bob pass  :", bobPass)

	// -----------------------------------------------------------------------------
	//  Validate CODE between alice and bob
	// -----------------------------------------------------------------------------

	// Alice validates the passcode from Bob
	if aliceKey.Validate(bobPass) {
		fmt.Println("Alice: Passcode is valid")
	}

	// Bob validates the passcode from Alice
	if bobKey.Validate(alicePass) {
		fmt.Println("Bob  : Passcode is valid")
	}

	// Output:
	// Alice Priv: 07b0ff9dc25884eb20af588269697001f9a580f96a7d318f21e0aa466f22da0a
	// Alice Pub : bbf54e526eb5ffd40e2e4791b2715eef8f51fd8f1817c9b7a1d9739a038b0647
	// Bob Priv  : 418c8e9102f8d695bec35be99f53eda380f9d7990a5447bc66dff87226135c16
	// Bob Pub   : 0b0f1cccc640e8ef08a0043b37d3e8f7a4fc4d027ccb433b053cb18b8f43bc66
	// Alice's Shared Secret: b9c22821ff75038ffa4fa2dddfc4af06c3797df7b92f87ade36952c515dab424 (len: 32)
	// Bob's Shared Secret  : b9c22821ff75038ffa4fa2dddfc4af06c3797df7b92f87ade36952c515dab424 (len: 32)
	// Alice TOTP sec key: 5fad6143564261a75e9cedeeb7b9f653...790e6bcf035d552c2817ed60dd9440a5 (len: 128)
	// Bob TOTP sec key  : 5fad6143564261a75e9cedeeb7b9f653...790e6bcf035d552c2817ed60dd9440a5 (len: 128)
	// Alice pass: 49641015
	// Bob pass  : 49641015
	// Alice: Passcode is valid
	// Bob  : Passcode is valid
}

func newECDH(curveType ecdh.Curve) (*ecdh.PrivateKey, *ecdh.PublicKey, error) {
	privKey, err := curveType.GenerateKey(rand.Reader)
	if err != nil {
		return nil, nil, err
	}

	pubKey := privKey.PublicKey()

	return privKey, pubKey, nil
}

// hash1024 hashes totpSec with ctx to 128 byte (1024 bit) length.
//
// ctx (context strings) must be hardcoded constants, and the recommended format
// is "[application] [commit timestamp] [purpose]",
//
//	e.g., "example.com 2019-12-25 16:18:03 session tokens v1".
func hash1024(totpSec []byte, ctx string) []byte {
	const outLen = 128

	outHash := make([]byte, outLen)

	blake3.DeriveKey(
		ctx,     // context
		totpSec, // material
		outHash,
	)

	return outHash
}

func newTOTP(issuer, accountName string, totpSec []byte) (*totp.Key, error) {
	totpKey, err := totp.GenerateKeyCustom(totp.Options{
		Issuer:      issuer,
		AccountName: accountName,
		Algorithm:   totp.Algorithm("SHA256"), // use SHA256 for HMAC
		Period:      30,                       // valid for 30 sec
		SecretSize:  128,                      // len TOTP key
		Skew:        0,                        // ± 0 time tolerance
		Digits:      totp.DigitsEight,         // 8 digits pass code
	})
	if err != nil {
		return nil, err
	}

	totpKey.Secret = totp.Secret(totpSec)

	return totpKey, nil
}

func panicOnErr(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

Feature: user defined key derivation (hash) function

Currently, deriving the TOTP secret from ECDH shared secret is fixed.

go-totp/totp/key.go

Lines 65 to 83 in 81e4905

if options.ecdhPrivateKey != nil && options.ecdhPublicKey != nil {
// Generate ECDH shared secret (32 bytes)
ecdhSecret, err := options.ecdhPrivateKey.ECDH(options.ecdhPublicKey)
if err != nil {
return nil, errors.Wrap(err, "failed to generate ECDH shared secret")
}
// Derivation of a secret key of length `options.SecretSize` from the
// shared secret.
outHash := make([]byte, options.SecretSize)
blake3.DeriveKey(
options.ecdhCtx, // context
ecdhSecret, // material
outHash,
)
internalSec = outHash
}

Allow users to define this functionality using interface.

Feature: Consistent secret key from a seed

Note: We decided not implement this. But consider ECDH to generate common secret as a secret key for TOTP.

Feature request

An option to generate a consistent secret key from a specified Seed.

opts := totp.Options{
  Issuer:      "Example.com",
  AccountName: "[email protected]",
  Algorithm:   totp.Algorithm("SHA1"),
  Period:      60,
  SecretSize:  20,
  Skew:        0,
  Digits:      totp.Digits(8),
  // If the Seed, Issuer and AccountName are the same, then the secret is the same.
  // If empty, cryptographically secure random secret is generated.
  Seed:        "mySecretSeed",
}

Reason

First of all, this feature request is not intended for those who have lost their TOTP secret key.

TOTP may have greater potential than simply being used for 2FA.

  • Example use cases
    1. Generate a consistent secret key from a private PGP key.
    2. TOTP passcode as a token between APIs.

Current workaround

Overwrite the secret after generation.

Ideas to implement

package main

import (
	"fmt"

	"github.com/zeebo/blake3"
)

func main() {
	SecretSize := 20
	Issuer := "Example.com"
	AccountName := "[email protected]"
	Seed := "mySecretSeed"

	// Hash the value with a blazingly fast cryptographic hash function
	hashByte := blake3.Sum512(
		[]byte(Issuer + AccountName + Seed),
	)

	// Map/compress the hash value to the required secret size
	secret := make([]byte, SecretSize)

	for index, data := range hashByte {
		ite := index % SecretSize
		secret[ite] ^= data
		// fmt.Println(secret, "->", data)
	}

	//fmt.Println("Hash:", hashByte)
	fmt.Printf("Final secret: %x", secret)

	// Output:
	// Final secret: 28152c20c5b6dd6c9a7b29b4f41ed997fc7170c1
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.