keyfactor — the registered key, touched
Did the key that was registered answer, and did a person touch it?
keyfactor is the one piece between fido,
which speaks CTAP and knows about no policy, and
mfa, which decides how much proof is
enough and knows about no device. Neither imports the other; this imports both.
go get github.com/go-authn/keyfactor@v0.4.2At v0.4.2 its go.mod requires at least go-authn/fido v0.6.2 and
go-authn/mfa v0.5.0, the versions these pages describe.
The platform supplies an Opener — how a key is found on this system — and
keeps only that. Everything else is the same wherever CTAP is: the assertion,
the checks, the refusal to spend somebody’s last PIN attempt.
Example
package main
import (
"context"
"crypto/ecdsa"
"errors"
"fmt"
"github.com/go-authn/fido"
"github.com/go-authn/keyfactor"
"github.com/go-authn/mfa"
)
func main() {
// The platform supplies the Opener: how a key is found on this system.
// This one finds nothing, and says so the way a policy understands.
open := func(context.Context) (fido.Transport, error) {
return nil, keyfactor.Unavailable(errors.New("no security key is plugged in"))
}
// From the registration: the credential ID and its public key, which
// fido.AuthData.PublicKey returns from the attestation.
var pub *ecdsa.PublicKey // nil here, to show the refusal
f := keyfactor.New(open, keyfactor.Options{
RPID: "example.test",
CredentialID: []byte{0x01, 0x02},
PublicKey: pub,
})
fmt.Println(f.Name(), f.Kind())
_, err := mfa.Verify(context.Background(), mfa.Policy{Count: 1}, f)
fmt.Println(err)
}your security key possession
mfa: 1 factor(s) needed, 0 answered: your security key (possession): keyfactor: no public key for the credential; without one any device that answers would pass, so pass the one from the registrationThe refusal comes before the Opener is called. With the registration’s
public key, the same factor opens the key, asks it for an assertion over a
fresh random challenge, and checks the answer.
What it refuses, and why
A device answering is not the key answering. A programmable USB board speaks CTAP as well as a security key does, so the answer is checked against the registration:
| refused | message |
|---|---|
before anything is opened: no Opener, no RPID, no PublicKey, or a key not on P-256 | keyfactor: no opener; …, … needs a relying party id …, … no public key for the credential; …, … is not on P-256 … |
a relying-party hash that is not SHA-256 of RPID | … answered for another relying party |
a credential other than CredentialID, when the device names one | … answered with a credential other than the one asked for |
a signature over the authenticator data and the challenge that PublicKey did not make | … answered with a signature the credential's key did not make |
no UP flag | … answered without anyone touching it |
no UV flag when a PIN was given | … was asked to establish who holds it and did not |
The last PIN attempt is not spent on a guess. PINRetries is asked first;
a key that will take no more PINs until it is unplugged is refused, and so is
one with one attempt or fewer left. Under CTAP, three wrong PINs in a row
block a key’s PIN until it is unplugged, and a retry counter that reaches zero
blocks it for good: only a reset lifts that, erasing every credential on it.
When the key itself refuses a PIN, the error is fido’s, and from fido v0.6.2
it names the right one of the two:
too many wrong PINs in a row; unplug the key and plug it in again (0x34)
when unplugging lifts the block, and
the PIN is blocked: no tries are left, and only a reset, which erases the credentials, unblocks it (0x32)
when only a reset does. Before fido v0.6.2 the two sentences were swapped.
A PIN is not a second factor. The kind stays mfa.Possession: a PIN
entered into the key protects the key, and identifies nobody to this machine.
An empty port is not a refusal. An Opener that finds nothing wraps its
error with keyfactor.Unavailable, which marks it mfa.ErrUnavailable, and a
policy counts could not be asked apart from said no.
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. That is the protocol. Use a long PIN, and do not
type one into a key whose provenance is unknown.
What it is checked against
The tests fake the authenticator, not the transport, built from
go-authn/fido’s own exported framing: a CTAPHID handshake, a CTAP2 assertion,
and a ClientPIN exchange. The PIN half plays the authenticator’s side of
protocol two independently — its own ECDH, HKDF and AES-CBC, written from the
specification (fakepin_test.go) — so a derivation that differed by a byte
would fail the exchange. forged_test.go is the device that holds no
credential. Coverage is held at 100%, and the tests run on eight
architectures.
Reference
Created on by tannevaled
Modified on by tannevaled