Skip to content

Repository files navigation

mfa

Time-based one-time passwords (TOTP, RFC 6238) and single-use recovery codes, for adding an authenticator app as a second factor.

Standard library only. No configuration, no storage layer, no dependencies.

go get github.com/hammondus/mfa

Demo Code

This is a Go module that needs to be imported into another Go application.

github.com/hammondus/mfademo is a complete working demo using this go module.

What it does

func NewSecret() string
func EncodeSecret(raw []byte) string
func DecodeSecret(s string) ([]byte, error)
func URI(issuer, account, secret string) string
func VerifyTOTP(secret []byte, code string, now time.Time, lastStep uint64) (Accepted, bool)

func NewRecoveryCodes(n int) []string
func HashRecovery(code string) []byte

func Seal(key, secret, aad []byte) ([]byte, error)
func Open(key, box, aad []byte) ([]byte, error)

Digits, period, hash, and skew are fixed at 6, 30 seconds, SHA-1, and one step. Authenticator apps assume those values and most ignore the URI parameters that claim otherwise, so a setting here would only offer a way to generate codes no app can reproduce.

What it does not do

Storage. The package holds no state. Replay counters, failure counts, and the secrets themselves are yours to persist, because only you can write them in the same transaction that grants a session. See Integrating with SQLite.

QR codes. URI returns the otpauth:// string; rendering it is two lines in your handler, and keeping it there keeps this package dependency-free:

c, err := qr.Encode(mfa.URI("Example", account, secret), qr.M) // rsc.io/qr
// serve c.PNG() as image/png

Show the secret as text beside the QR code. Every authenticator app accepts manual entry, and some people cannot scan.

Passwords. Hash those with Argon2id. The reasoning that makes SHA-256 right for recovery codes does not transfer.

Integrating with SQLite

Connection

Open the database with an immediate-transaction lock and a busy timeout:

file:app.db?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)

_txlock=immediate is the important one. SQLite starts a transaction deferred by default, so it takes the write lock only at the first write. Two logins racing with the same code both read the old last_step, and the second gets SQLITE_BUSY on upgrade rather than a clean serialisation. Taking the write lock at BEGIN is what makes the replay check hold. The DSN spelling above is for modernc.org/sqlite; mattn/go-sqlite3 uses _txlock=immediate too but spells the pragmas differently.

Schema

CREATE TABLE mfa_totp (
  user_id      INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  secret       BLOB    NOT NULL,          -- mfa.Seal output
  last_step    INTEGER NOT NULL DEFAULT 0,
  confirmed_at INTEGER,                   -- NULL while enrolment is pending
  failed       INTEGER NOT NULL DEFAULT 0,
  locked_until INTEGER
) STRICT;

CREATE TABLE mfa_recovery (
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  hash    BLOB    NOT NULL,               -- mfa.HashRecovery output
  used_at INTEGER,
  PRIMARY KEY (user_id, hash)
) STRICT, WITHOUT ROWID;

confirmed_at IS NULL carries a pending enrolment in the same row, so there is no second table to keep in step. Treat MFA as enabled only when it is non-null.

Bind each secret to its row:

func aad(userID int64) []byte {
	return []byte("mfa_totp:" + strconv.FormatInt(userID, 10))
}

An attacker with database write access who copies one user's secret onto another's row then gets a decryption failure instead of a working second factor.

Enrolling

Write the secret unconfirmed, show the URI, and enable nothing yet:

secret := mfa.NewSecret()
raw, err := mfa.DecodeSecret(secret)
if err != nil {
	return err
}
box, err := mfa.Seal(s.key, raw, aad(uid))
if err != nil {
	return err
}
_, err = s.db.ExecContext(ctx,
	`INSERT INTO mfa_totp (user_id, secret, last_step, confirmed_at)
	 VALUES (?, ?, 0, NULL)
	 ON CONFLICT(user_id) DO UPDATE SET
	   secret = excluded.secret, last_step = 0, confirmed_at = NULL`,
	uid, box)

Then display mfa.URI(issuer, account, secret) and require one valid code before setting confirmed_at. Enrolling without that proof locks people out of their own accounts when the phone clock is wrong or the wrong entry gets scanned.

The base32 secret exists only for as long as the handler that minted it. To show the page again — a reload, or someone coming back to finish — read the row and re-derive it:

raw, err := mfa.Open(s.key, box, aad(uid))
if err != nil {
	return err
}
secret := mfa.EncodeSecret(raw)

Issue recovery codes in the same transaction that confirms enrolment:

codes := mfa.NewRecoveryCodes(mfa.RecoveryCodeCount)
for _, c := range codes {
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO mfa_recovery (user_id, hash) VALUES (?, ?)`,
		uid, mfa.HashRecovery(c)); err != nil {
		return err
	}
}
// Show codes to the user once. There is no way to show them again.

Verifying a login

const (
	maxFailures = 5
	lockout     = 15 * time.Minute
)

// CheckTOTP verifies code for uid and spends it. It reports whether the code
// was accepted; an unenrolled, locked, or wrong-code user all get false.
func (s *Store) CheckTOTP(ctx context.Context, uid int64, code string) (bool, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer tx.Rollback()

	var (
		box              []byte
		lastStep, failed int64
		lockedUntil      sql.NullInt64
	)
	err = tx.QueryRowContext(ctx,
		`SELECT secret, last_step, failed, locked_until
		   FROM mfa_totp
		  WHERE user_id = ? AND confirmed_at IS NOT NULL`,
		uid).Scan(&box, &lastStep, &failed, &lockedUntil)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return false, nil
	case err != nil:
		return false, err
	}

	now := time.Now()
	if lockedUntil.Valid && now.Unix() < lockedUntil.Int64 {
		return false, nil
	}

	secret, err := mfa.Open(s.key, box, aad(uid))
	if err != nil {
		return false, err
	}

	acc, ok := mfa.VerifyTOTP(secret, code, now, uint64(lastStep))
	if !ok {
		failed++
		var until any // nil writes NULL
		if failed >= maxFailures {
			until = now.Add(lockout).Unix()
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE mfa_totp SET failed = ?, locked_until = ? WHERE user_id = ?`,
			failed, until, uid); err != nil {
			return false, err
		}
		return false, tx.Commit()
	}

	// Spending the step is not optional. Without this write the code stays
	// valid for the rest of the skew window.
	if _, err := tx.ExecContext(ctx,
		`UPDATE mfa_totp
		    SET last_step = ?, failed = 0, locked_until = NULL
		  WHERE user_id = ?`,
		int64(acc.Step), uid); err != nil {
		return false, err
	}
	return true, tx.Commit()
}

Redeeming a recovery code

The hash is deterministic, so one conditional statement both checks and spends the code. No transaction, no read-then-write race:

res, err := s.db.ExecContext(ctx,
	`UPDATE mfa_recovery SET used_at = ?
	  WHERE user_id = ? AND hash = ? AND used_at IS NULL`,
	time.Now().Unix(), uid, mfa.HashRecovery(code))
if err != nil {
	return false, err
}
n, err := res.RowsAffected()
return n == 1, err

One row affected means the code was valid and unspent. Zero means it was wrong or already used, and those two must be indistinguishable to the caller.

Operational notes

  • Lock the account, not the IP. Six digits across a three-step window is a small space. Per-IP limits are bypassed with a botnet; consecutive-failure lockout on the account is the control that works.
  • Keep the encryption key out of the database. Environment variable or key manager. A key stored beside the ciphertext protects nothing.
  • Do not reveal MFA status before authentication. Ask for the code only after the password verifies, and make a wrong code look like a wrong password in both timing and response.
  • Require a password re-entry before disabling MFA or reissuing recovery codes, so a stolen session cannot remove the second factor.
  • Do not offer SMS as the fallback. NIST SP 800-63B has treated it as a restricted authenticator for years. The recovery codes are the fallback.
  • Rotate on redemption. When someone burns a recovery code, tell them how many remain and prompt to reissue at zero.

Tests

make test runs vet and the unit tests. The TOTP path is checked against the published vectors in RFC 4226 appendix D and RFC 6238 appendix B.

About

TOTP and single-use recovery codes for a second factor. Standard library only.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages