keyfactor

package module
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: BSD-3-Clause Imports: 10 Imported by: 0

README

keyfactor

Go Reference License CI

Turns a security key into a factor a policy can ask. Pure Go, CGO_ENABLED=0, no platform.

// From the registration: the credential ID and the credential's public key.
pub, err := attestation.Parsed.PublicKey() // *ecdsa.PublicKey, P-256
// An Opener takes the attempt's context; go-gnulinux/fido's Transport does not.
open := func(context.Context) (fido.Transport, error) { return linuxfido.Transport() }
f := keyfactor.New(open, keyfactor.Options{
    RPID:         "example.test",
    CredentialID: id,
    PublicKey:    pub, // required: Verify refuses a factor without one
})
r, err := mfa.Verify(ctx, mfa.Policy{Count: 2}, somethingElse, f)

It is the piece between go-authn/fido, which speaks CTAP and knows about no operating system, and go-authn/mfa, which decides how much proof is enough and knows about no device. Neither should import the other.

Why it is not in a platform package

It was, twice — once for macOS and once, nearly, for Linux. The two copies differed in one line: which transport to open. Everything else — the assertion, the flag checks, the refusal to spend somebody's last PIN attempt — is the same everywhere CTAP is, which is everywhere.

So the platform package supplies an Opener and keeps what is genuinely platform-specific: how a key is found, and what it means when there is none.

What it refuses to confuse

  • A device answering is not the key answering. A programmable USB board speaks CTAP as well as a security key does. The answer is verified against the registration: the relying-party hash must be SHA-256 of RPID, the credential the device names (when it names one) must be CredentialID, and the signature over the authenticator data and the challenge must verify under PublicKey. Before this, a device holding no credential — zero rpIdHash, one-byte signature, somebody else's credential ID, UP|UV claimed — satisfied the factor. A factor with no PublicKey, or one not on P-256, is refused before anything is opened.

  • A key answering is not a person being there. The UP flag is checked, and UV when a PIN was used. A request for verification is not proof of it: a key may be asked and simply not do it.

  • A PIN is not a second factor. The kind stays Possession. A PIN entered into the key never reaches this machine and identifies nobody to us — it protects the key. Counting it would let one object masquerade as two.

  • The last PIN attempt is not spent on a guess. A wrong PIN costs a retry, a key that runs out locks until it is unplugged and then permanently, taking every credential on it. The count is asked for first, and a key that will not say is refused rather than tried.

  • An empty port is not a refusal. The Opener decides — it is the only thing that knows — and its verdict is passed through untouched. An Opener that finds nothing wraps its error with keyfactor.Unavailable, which marks it mfa.ErrUnavailable, so a policy counts "could not be asked" apart from "said no".

  • The challenge is random, and the verified signature covers it: a fixed challenge would let a recorded assertion be replayed here for ever.

What it cannot protect

The PIN's hash reaches whatever device answers. CTAP has the platform hand the key LEFT(SHA-256(PIN), 16), encrypted under a secret agreed with the device itself, before anything is signed. A forged device is refused as a factor, but it has received that hash, and a short PIN falls to an offline search in moments (the tests do it for a four-digit one). That is the protocol, not this package: use a long PIN, and do not type one into a key whose provenance you do not know.

Covered to 100%, with no key

The tests fake the authenticator, not the transport: a CTAPHID handshake, a CTAP2 assertion, and a real ClientPIN exchange, built out of go-authn/fido's own exported framing. Stopping at the wire would have left everything this package does untested.

⭐ The PIN half plays the authenticator's side of protocol two independently — its own ECDH, its own HKDF, its own AES-CBC, written from the specification rather than from the code under test. If the two derivations disagreed by a byte the exchange would fail. A fake that reused the implementation would agree with it for free, including when both were wrong.

Documentation

Overview

Package keyfactor turns a security key into a factor a policy can ask.

It is the piece between github.com/go-authn/fido, which speaks CTAP and knows about no operating system, and github.com/go-authn/mfa, which decides how much proof is enough and knows about no device. Neither should import the other; this does both, and nothing else does.

// From the registration: the credential ID and its public key.
pub, err := attestation.Parsed.PublicKey()
open := func(context.Context) (fido.Transport, error) { return linuxfido.Transport() }
f := keyfactor.New(open, keyfactor.Options{
    RPID:         "example.test",
    CredentialID: id,
    PublicKey:    pub,
})
r, err := mfa.Verify(ctx, mfa.Policy{Count: 2}, somethingElse, f)

What is checked

Whatever answers on the transport is a device, not necessarily THE key: a programmable USB board can speak CTAP as well as a security key can. So the answer is verified against what the registration recorded -- the relying party hash, the credential ID when the device names one, and the signature over the authenticator data and a fresh random challenge, with the credential's public key. A device that does not hold the credential's private key cannot produce that signature, whatever flags it claims.

What is not, and cannot be

A PIN is sent to whatever device answers, before anything is signed: CTAP has the platform hand the key LEFT(SHA-256(PIN), 16), encrypted under a secret agreed with the device itself. A forged device holding no credential is refused here, but it has still received that hash, and a short PIN falls to an offline search in moments. That is inherent to CTAP, not something this package can fix: use a long PIN, and do not type one into a key whose provenance is unknown.

Why it is not in a platform package

It was, twice — once for macOS and once, nearly, for Linux. The two copies differed in one line: which fido.Transport to open. Everything else — the assertion, the flag checks, the refusal to spend somebody's last PIN attempt — is the same everywhere CTAP is, which is everywhere.

So the platform package supplies an Opener and keeps what is genuinely platform-specific: how a key is found, and what it means when there is none.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func New

func New(open Opener, o Options) mfa.Factor

New makes a factor.

func Unavailable

func Unavailable(err error) error

Unavailable marks an error as "there was nothing here to ask".

Types

type Opener

type Opener func(context.Context) (fido.Transport, error)

Opener produces a transport to the attached key.

It is where the platform lives: go-macos/fido and go-gnulinux/fido each have one. An Opener that finds nothing should wrap its error with mfa.ErrUnavailable — through Unavailable — because an empty USB port has refused nobody, and a policy counts "could not be asked" separately from "said no".

type Options

type Options struct {
	// RPID is the relying party the credential belongs to. Required.
	RPID string
	// CredentialID is what a registration returned. Empty asks the key for a
	// discoverable credential, which it has only if one was registered with
	// the "rk" option. When set, an answer naming any other credential is
	// refused.
	CredentialID []byte
	// PublicKey is the credential's public key, from the registration
	// (fido.AuthData.PublicKey on the attestation). Required: the signature
	// it verifies is the only thing that tells the key that was registered
	// from any device that answers CTAP. Only P-256 is accepted.
	PublicKey *ecdsa.PublicKey
	// PIN, when set, asks the key to establish WHO is holding it rather than
	// only that somebody is.
	//
	// It travels no further than the key: what goes over the wire is the first
	// sixteen bytes of its SHA-256, encrypted under a secret agreed for that
	// one exchange. It is still a secret in a Go string, so a caller that can
	// avoid holding one should.
	PIN string
	// Name is what to call this key when talking to a person. Empty picks a
	// sensible default.
	Name string
}

Options describes what to ask the key.

Jump to

Keyboard shortcuts

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