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
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.
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.
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/pngShow 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.
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.
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.
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.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()
}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, errOne 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.
- 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.
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.