mirror of
https://github.com/1f349/twofactor.git
synced 2024-12-21 15:04:11 +00:00
594 lines
19 KiB
Go
594 lines
19 KiB
Go
package twofactor
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"math"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/sec51/convert"
|
|
"github.com/sec51/convert/bigendian"
|
|
qr "github.com/sec51/qrcode"
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
var (
|
|
initializationFailedError = errors.New("Totp has not been initialized correctly")
|
|
LockDownError = errors.New("The verification is locked down, because of too many trials.")
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
// This is used internally
|
|
func (otp *Totp) synchronizeCounter(offset int) {
|
|
otp.clientOffset = offset
|
|
}
|
|
|
|
// Label returns the combination of issuer:account string
|
|
func (otp *Totp) label() string {
|
|
return fmt.Sprintf("%s:%s", url.QueryEscape(otp.issuer), otp.account)
|
|
}
|
|
|
|
// Counter returns the TOTP's 8-byte counter as unsigned 64-bit integer.
|
|
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
|
|
// 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))
|
|
}
|
|
|
|
// sanitize the digits range otherwise it may create invalid tokens !
|
|
if digits < 6 || digits > 8 {
|
|
digits = 8
|
|
}
|
|
|
|
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)
|
|
otp.key = key
|
|
otp.account = account
|
|
otp.issuer = issuer
|
|
otp.digits = digits
|
|
otp.stepSize = 30 // we set it to 30 seconds which is the recommended value from the RFC
|
|
otp.clientOffset = 0
|
|
otp.hashFunction = hash
|
|
return otp, nil
|
|
}
|
|
|
|
func NewTOTPWithKey(key []byte, account, issuer string, hash crypto.Hash, digits int) (*Totp, error) {
|
|
otp := new(Totp)
|
|
otp.key = key
|
|
otp.account = account
|
|
otp.issuer = issuer
|
|
otp.digits = digits
|
|
otp.stepSize = 30 // we set it to 30 seconds which is the recommended value from the RFC
|
|
otp.clientOffset = 0
|
|
otp.hashFunction = hash
|
|
return otp, nil
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
func (otp *Totp) Validate(userCode string) error {
|
|
|
|
// check Totp initialization
|
|
if err := totpHasBeenInitialized(otp); err != nil {
|
|
return err
|
|
}
|
|
|
|
// verify that the token is valid
|
|
if userCode == "" {
|
|
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 >= max_failures && validBackoffTime(otp.lastVerificationTime) {
|
|
// reset the total verification failures counter
|
|
otp.totalVerificationFailures = 0
|
|
}
|
|
|
|
// calculate the sha256 of the user code
|
|
userTokenHash := sha256.Sum256([]byte(userCode))
|
|
userToken := hex.EncodeToString(userTokenHash[:])
|
|
|
|
// 1 calculate the 3 tokens
|
|
tokens := make([]string, 3)
|
|
token0Hash := sha256.Sum256([]byte(CalculateTOTP(otp, -1)))
|
|
token1Hash := sha256.Sum256([]byte(CalculateTOTP(otp, 0)))
|
|
token2Hash := sha256.Sum256([]byte(CalculateTOTP(otp, 1)))
|
|
|
|
tokens[0] = hex.EncodeToString(token0Hash[:]) // 30 seconds ago token
|
|
tokens[1] = hex.EncodeToString(token1Hash[:]) // current token
|
|
tokens[2] = hex.EncodeToString(token2Hash[:]) // next 30 seconds token
|
|
|
|
// if the current time token is valid then, no need to re-sync and return nil
|
|
if tokens[1] == userToken {
|
|
return nil
|
|
}
|
|
|
|
// if the 30 seconds ago token is valid then return nil, but re-synchronize
|
|
if tokens[0] == userToken {
|
|
otp.synchronizeCounter(-1)
|
|
return nil
|
|
}
|
|
|
|
// if the let's say 30 seconds ago token is valid then return nil, but re-synchronize
|
|
if tokens[2] == userToken {
|
|
otp.synchronizeCounter(1)
|
|
return nil
|
|
}
|
|
|
|
otp.totalVerificationFailures++
|
|
otp.lastVerificationTime = time.Now().UTC() // important to have it in UTC
|
|
|
|
// if we got here everything is good
|
|
return errors.New("Tokens mismatch.")
|
|
}
|
|
|
|
// 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)
|
|
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.
|
|
func (otp *Totp) incrementCounter(index int) {
|
|
// Unix returns t as a Unix time, the number of seconds elapsed since January 1, 1970 UTC.
|
|
counterOffset := time.Duration(index*otp.stepSize) * time.Second
|
|
now := time.Now().UTC().Add(counterOffset).Unix()
|
|
otp.counter = bigendian.ToUint64(increment(now, otp.stepSize))
|
|
}
|
|
|
|
// Function which calculates the value of T (see rfc6238)
|
|
func increment(ts int64, stepSize int) uint64 {
|
|
T := float64(ts / int64(stepSize)) // TODO: improve this conversions
|
|
n := convert.Round(T) // round T
|
|
return n // convert n to little endian byte array
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// it uses the index 0, meaning that it calculates the current one
|
|
return CalculateTOTP(otp, 0), nil
|
|
}
|
|
|
|
// 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
|
|
|
|
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
|
|
// this is necessary to generate the proper OTP
|
|
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)
|
|
}
|
|
|
|
// 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())
|
|
|
|
mod := int32(result % int64(math.Pow10(digits)))
|
|
|
|
fmtStr := fmt.Sprintf("%%0%dd", digits)
|
|
|
|
return fmt.Sprintf(fmtStr, mod)
|
|
}
|
|
|
|
// Secret returns the underlying base32 encoded secret.
|
|
// This should only be displayed the first time a user enables 2FA,
|
|
// and should be transmitted over a secure connection.
|
|
// Useful for supporting TOTP clients that don't support QR scanning.
|
|
func (otp *Totp) Secret() string {
|
|
return base32.StdEncoding.EncodeToString(otp.key)
|
|
}
|
|
|
|
// 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) {
|
|
|
|
// verify the proper initialization
|
|
if err := totpHasBeenInitialized(otp); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
secret := base32.StdEncoding.EncodeToString(otp.key)
|
|
u := url.URL{}
|
|
v := url.Values{}
|
|
u.Scheme = "otpauth"
|
|
u.Host = "totp"
|
|
u.Path = otp.label()
|
|
v.Add("secret", secret)
|
|
v.Add("counter", fmt.Sprintf("%d", otp.getIntCounter()))
|
|
v.Add("issuer", otp.issuer)
|
|
v.Add("digits", strconv.Itoa(otp.digits))
|
|
v.Add("period", strconv.Itoa(otp.stepSize))
|
|
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
|
|
// 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()
|
|
|
|
// check for errors during initialization
|
|
// this is already done on the URL method
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
code, err := qr.Encode(u, qr.Q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return code.PNG(), nil
|
|
}
|
|
|
|
// 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) {
|
|
|
|
// check Totp initialization
|
|
if err := totpHasBeenInitialized(otp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
|
|
// calculate the length of the key and create its byte representation
|
|
keySize := len(otp.key)
|
|
keySizeBytes := bigendian.ToInt(keySize) //bigEndianInt(keySize)
|
|
|
|
// calculate the length of the issuer and create its byte representation
|
|
issuerSize := len(otp.issuer)
|
|
issuerSizeBytes := bigendian.ToInt(issuerSize)
|
|
|
|
// calculate the length of the account and create its byte representation
|
|
accountSize := len(otp.account)
|
|
accountSizeBytes := bigendian.ToInt(accountSize)
|
|
|
|
totalSize := 4 + 4 + keySize + 8 + 4 + 4 + issuerSize + 4 + accountSize + 4 + 4 + 4 + 8 + 4
|
|
totalSizeBytes := bigendian.ToInt(totalSize)
|
|
|
|
// at this point we are ready to write the data to the byte buffer
|
|
// total size
|
|
if _, err := buffer.Write(totalSizeBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// key
|
|
if _, err := buffer.Write(keySizeBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := buffer.Write(otp.key); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// counter
|
|
counterBytes := bigendian.ToUint64(otp.getIntCounter())
|
|
if _, err := buffer.Write(counterBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// digits
|
|
digitBytes := bigendian.ToInt(otp.digits)
|
|
if _, err := buffer.Write(digitBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// issuer
|
|
if _, err := buffer.Write(issuerSizeBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := buffer.WriteString(otp.issuer); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// account
|
|
if _, err := buffer.Write(accountSizeBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := buffer.WriteString(otp.account); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// steps
|
|
stepsBytes := bigendian.ToInt(otp.stepSize)
|
|
if _, err := buffer.Write(stepsBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// offset
|
|
offsetBytes := bigendian.ToInt(otp.clientOffset)
|
|
if _, err := buffer.Write(offsetBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// total_failures
|
|
totalFailuresBytes := bigendian.ToInt(otp.totalVerificationFailures)
|
|
if _, err := buffer.Write(totalFailuresBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// last verification time
|
|
verificationTimeBytes := bigendian.ToUint64(uint64(otp.lastVerificationTime.Unix()))
|
|
if _, err := buffer.Write(verificationTimeBytes[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// has_function_type
|
|
switch otp.hashFunction {
|
|
case crypto.SHA256:
|
|
sha256Bytes := bigendian.ToInt(1)
|
|
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 {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return buffer.Bytes(), nil
|
|
|
|
}
|
|
|
|
// 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(message []byte, issuer string) (*Totp, error) {
|
|
// new reader
|
|
reader := bytes.NewReader([]byte(message))
|
|
|
|
// otp object
|
|
otp := new(Totp)
|
|
|
|
// get the length
|
|
length := make([]byte, 4)
|
|
_, err := reader.Read(length) // read the 4 bytes for the total length
|
|
if err != nil && err != io.EOF {
|
|
return otp, err
|
|
}
|
|
|
|
totalSize := bigendian.FromInt([4]byte{length[0], length[1], length[2], length[3]})
|
|
buffer := make([]byte, totalSize-4)
|
|
_, err = reader.Read(buffer)
|
|
if err != nil && err != io.EOF {
|
|
return otp, err
|
|
}
|
|
|
|
// skip the total bytes size
|
|
startOffset := 0
|
|
// read key size
|
|
endOffset := startOffset + 4
|
|
keyBytes := buffer[startOffset:endOffset]
|
|
keySize := bigendian.FromInt([4]byte{keyBytes[0], keyBytes[1], keyBytes[2], keyBytes[3]})
|
|
|
|
// read the key
|
|
startOffset = endOffset
|
|
endOffset = startOffset + keySize
|
|
otp.key = buffer[startOffset:endOffset]
|
|
|
|
// read the counter
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 8
|
|
b := buffer[startOffset:endOffset]
|
|
otp.counter = [8]byte{b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]}
|
|
|
|
// read the digits
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
otp.digits = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]}) //
|
|
|
|
// read the issuer size
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
issuerSize := bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
// read the issuer string
|
|
startOffset = endOffset
|
|
endOffset = startOffset + issuerSize
|
|
otp.issuer = string(buffer[startOffset:endOffset])
|
|
|
|
// read the account size
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
accountSize := bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
// read the account string
|
|
startOffset = endOffset
|
|
endOffset = startOffset + accountSize
|
|
otp.account = string(buffer[startOffset:endOffset])
|
|
|
|
// read the steps
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
otp.stepSize = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
// read the offset
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
otp.clientOffset = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
// read the total failures
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
otp.totalVerificationFailures = bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
// read the offset
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 8
|
|
b = buffer[startOffset:endOffset]
|
|
ts := bigendian.FromUint64([8]byte{b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]})
|
|
otp.lastVerificationTime = time.Unix(int64(ts), 0)
|
|
|
|
// read the hash type
|
|
startOffset = endOffset
|
|
endOffset = startOffset + 4
|
|
b = buffer[startOffset:endOffset]
|
|
hashType := bigendian.FromInt([4]byte{b[0], b[1], b[2], b[3]})
|
|
|
|
switch hashType {
|
|
case 1:
|
|
otp.hashFunction = crypto.SHA256
|
|
break
|
|
case 2:
|
|
otp.hashFunction = crypto.SHA512
|
|
break
|
|
default:
|
|
otp.hashFunction = crypto.SHA1
|
|
}
|
|
|
|
return otp, err
|
|
}
|
|
|
|
// 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 nil
|
|
}
|