mfa

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: BSD-3-Clause Imports: 6 Imported by: 6

README

mfa

Go Reference License CI

Decides whether enough factors answered, and says which ones. Pure Go, no platform, no cryptography, no device.

r, err := mfa.Verify(ctx, mfa.Policy{Count: 2, DistinctKinds: true},
    factors.TouchID("unlock the vault"),         // go-macos/factors, over LocalAuthentication
    factors.SecurityKey("example.test", credID), // go-macos/factors, over go-authn/fido
)
if err != nil {
    fmt.Println(err)   // "mfa: 2 factor(s) needed, 1 answered: your security key (possession): not available"
}

Two of the same thing is one factor

The phrase is multi-factor, not multi-check. A passphrase and a recovery code are both things you know: whoever learned one has usually learned the other from the same place, and requiring both buys far less than the count suggests.

So the classification is the point. Policy{Count: 2, DistinctKinds: true} is what two-factor is normally taken to mean, and two passphrases never satisfy it. Policy{Count: 2} on its own is also offered, because a caller who wants two keys — two of the same kind, deliberately, so losing one is survivable — is asking for something real. It is simply not the same request, and the two are not spelled the same way here.

The three kinds are the classical ones: Knowledge (something you know), Possession (something you have), Inherence (something you are). A factor that does not classify itself counts towards the number satisfied but never towards the kinds — it cannot be shown to differ from anything.

What it refuses to confuse

  • A factor that could not be asked has not failed. A laptop with no fingerprint reader refused nobody. ErrUnavailable is reported separately, and it never ends an attempt. An adapter returns mfa.Unavailable(err), which wraps that sentinel and keeps the reason (an empty USB port, nobody enrolled); Answer.Unavailable reads it back.
  • Every factor is asked by default. Telling a person your key answered, your fingerprint did not needs both answers; stopping at the first failure tells them one thing at a time, across as many attempts as they have factors. StopOnFirstFailure is there when that is wanted, and off otherwise.
  • A factor not reached is not reported as failed. It has no entry in the result at all, because saying it failed would send somebody after nothing.
  • The same factor twice is one factor. Two factors of the same type holding the same values are refused before anything is asked: counted twice, one passphrase passed twice satisfied Count: 2. Two keys that differ in anything — a name, a credential ID — are two factors. ⛔ "The same values" includes funcs, which reflect.DeepEqual (used here first) never finds equal: a factor holding an opener or a callback, as go-authn/keyfactor's does, passed twice, was counted twice. A func is now the same func when it runs the same code. That is all reflection can see, so two closures from one literal that differ only in what they captured are taken for one factor and refused — the safe side; give them any other field that differs.
  • A kind outside the enum is not a kind. A factor whose Kind() is not Unknown, Knowledge, Possession or Inherence is refused, whatever the policy, before anything is asked: counted as a distinct kind, Kind(42) next to a passphrase passed as two-factor.
  • There is no default policy. The zero Policy is refused rather than guessed at: how much proof is enough belongs to whoever is protecting the thing, not to a library.

Where the factors come from

Nothing here reaches a device, on purpose: this package decides, and the things it asks live elsewhere. On macOS they are go-macos/factors — Touch ID over LocalAuthentication, and a security key over go-authn/fido.

A binding does not implement Factor itself. If it did, every program that only wanted to show a Touch ID prompt would pull in a policy package, so the adapters sit above both and nothing below them knows what a policy is.

Covered to 100%, on every platform, with no hardware.

Documentation

Overview

Package mfa decides whether enough factors answered, and says which ones.

It holds no cryptography and speaks to no device. A Factor is anything that can be asked "is this person here, now?" and answer yes, no, or "I cannot be asked at the moment" -- Touch ID through go-macos/localauthentication, a security key through go-authn/fido, a passphrase, a code from a phone. What is here is the part that has to be right whatever those are: how many answers, of which kinds, and what to say when some of them fail.

Two of the same thing is one factor

The phrase is multi-FACTOR, not multi-check. A password and a security question are both things you know: someone who learned one has usually learned the other from the same place, and requiring both buys far less than the count suggests. The classification is the point of the exercise, so Policy.DistinctKinds asks for answers of DIFFERENT kinds and is the setting that means what "two-factor" is normally taken to mean.

Policy.Count alone is also offered, because a caller who wants two keys -- two of the same kind, deliberately, so that losing one is survivable -- is asking for something real. It is simply not the same request, and the two are not spelled the same way here.

Index

Constants

This section is empty.

Variables

View Source
var ErrUnavailable = errors.New("mfa: this factor cannot be asked here")

ErrUnavailable means a factor could not be asked. It is not a failure: a laptop with no fingerprint reader has not refused anyone.

Functions

func Unavailable added in v0.2.0

func Unavailable(err error) error

Unavailable marks an error as "there was nothing here to ask", keeping the reason: an empty USB port, a person with no authenticator enrolled, a sensor this machine does not have.

It lives here rather than in each adapter because it constructs THIS package's sentinel, and because the second adapter that needed it was about to copy the first one's three lines. Answer.Unavailable is the other half.

Types

type Answer

type Answer struct {
	Name string
	Kind Kind
	// Err is nil when the factor was satisfied, [ErrUnavailable] when it could
	// not be asked, and whatever it returned otherwise.
	Err error
}

Answer is what one factor said.

func (Answer) OK

func (a Answer) OK() bool

OK reports whether this factor was satisfied.

func (Answer) String

func (a Answer) String() string

String renders the answer the way a log line reads.

func (Answer) Unavailable

func (a Answer) Unavailable() bool

Unavailable reports whether the factor could not be asked at all.

type Factor

type Factor interface {
	// Name is what a person is told to do: "Touch ID", "your security key".
	Name() string
	// Kind is what this factor proves.
	Kind() Kind
	// Verify asks it.
	Verify(ctx context.Context) error
}

Factor is one way of asking whether the person is here.

Verify returns nil when the factor is satisfied. It should return ErrUnavailable, wrapped or not, when it cannot be asked at all -- no key plugged in, no sensor on this machine -- which a policy treats differently from a refusal: a factor that was never asked has not failed.

type Kind

type Kind int

Kind is what a factor proves. The three are the classical ones, and the distinction is not decoration: Policy.DistinctKinds counts by it.

const (
	// Unknown is a factor that did not say. It never satisfies a policy that
	// asks for distinct kinds, because an unclassified factor cannot be shown
	// to differ from another.
	Unknown Kind = iota
	// Knowledge is something the person knows: a passphrase, a PIN, a recovery
	// code.
	Knowledge
	// Possession is something they have: a security key, a phone, a smart
	// card.
	Possession
	// Inherence is something they are: a fingerprint, a face, a voice.
	Inherence
)

The kinds of proof.

func (Kind) String

func (k Kind) String() string

String names the kind.

type Policy

type Policy struct {
	// Count is how many factors must be satisfied.
	Count int
	// DistinctKinds requires the satisfied factors to be of different kinds --
	// what "two-factor" is normally taken to mean. With it, two passphrases
	// never satisfy a Count of two.
	DistinctKinds bool
	// StopOnFirstFailure abandons the attempt as soon as one factor refuses,
	// instead of asking the rest.
	//
	// It is false by default, deliberately. Asking every factor and reporting
	// all of the answers is what lets a person be told "your key answered, your
	// fingerprint did not" -- and asking a security key AFTER a fingerprint has
	// already failed costs nothing but a moment, while stopping early tells
	// them one thing at a time across as many attempts as they have factors.
	StopOnFirstFailure bool
}

Policy is what "enough" means.

The zero Policy is not usable and Verify says so rather than guessing: there is no sensible default for how much proof is enough, and a library that picked one would be deciding something that belongs to whoever is protecting the thing.

type Result

type Result struct {
	// Answers is one entry per factor, in the order they were asked. A factor
	// not reached because [Policy.StopOnFirstFailure] ended the attempt has no
	// entry: it was not asked, and saying it failed would be a lie.
	Answers []Answer
	// Satisfied is how many answered yes, and Kinds how many DISTINCT kinds
	// those were.
	Satisfied int
	Kinds     int
}

Result is what every factor said, and whether that was enough.

func Verify

func Verify(ctx context.Context, p Policy, factors ...Factor) (Result, error)

Verify asks the factors and decides.

The error, when there is one, says what was missing in words a person can act on -- which factor refused, or that two answers of the same kind are not two factors. The Result is returned whether or not the policy was met, because a caller that must tell somebody what to try next needs the detail either way.

func (Result) String

func (r Result) String() string

String summarises the attempt.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL