internal/nfcauth/auth.go
raw ยท 7653 bytes
// SPDX-License-Identifier: Apache-2.0
// Home Key NFC authentication, based on kormax/apple-home-key-reader;
// see NOTICE. This implementation uses the STANDARD flow for enrolled devices.
package nfcauth
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"math/big"
"golang.org/x/crypto/hkdf"
"homekey.local/provisioner/internal/provision"
)
type Transport interface {
Exchange(context.Context, []byte) ([]byte, error)
}
type Result struct {
IssuerID, EndpointID, PublicKey string
PersistentKey []byte
}
func tlv(tag byte, value []byte) []byte {
b := []byte{tag}
if len(value) < 128 {
b = append(b, byte(len(value)))
} else {
b = append(b, 0x81, byte(len(value)))
}
return append(b, value...)
}
// Authentication messages use BER-TLV, not HAP's TLV8.
func fields(data []byte) (map[byte][]byte, error) {
f := map[byte][]byte{}
for len(data) > 0 {
if len(data) < 2 {
return nil, fmt.Errorf("truncated BER-TLV")
}
tag, n := data[0], int(data[1])
data = data[2:]
if tag&0x1f == 0x1f {
return nil, fmt.Errorf("unsupported BER-TLV tag")
}
if n == 0x81 {
if len(data) < 1 {
return nil, fmt.Errorf("truncated BER length")
}
n = int(data[0])
data = data[1:]
if n < 128 {
return nil, fmt.Errorf("noncanonical BER length")
}
} else if n >= 128 {
return nil, fmt.Errorf("unsupported BER length")
}
if n > len(data) {
return nil, fmt.Errorf("truncated BER value")
}
if _, ok := f[tag]; ok {
return nil, fmt.Errorf("duplicate BER tag %02x", tag)
}
f[tag] = append([]byte(nil), data[:n]...)
data = data[n:]
}
return f, nil
}
func checked(ctx context.Context, t Transport, name string, cmd []byte) ([]byte, error) {
r, e := t.Exchange(ctx, cmd)
if e != nil {
return nil, fmt.Errorf("%s: %w", name, e)
}
if len(r) < 2 {
return nil, fmt.Errorf("%s: missing status word", name)
}
if !bytes.Equal(r[len(r)-2:], []byte{0x90, 0}) {
return nil, fmt.Errorf("%s: status %02x%02x", name, r[len(r)-2], r[len(r)-1])
}
return r[:len(r)-2], nil
}
func command(ins, p1, p2 byte, data []byte) []byte {
return append([]byte{0x80, ins, p1, p2, byte(len(data))}, data...)
}
func transcript(id, deviceX, readerX, nonce, constant []byte) []byte {
b := tlv(0x4d, id)
b = append(b, tlv(0x86, deviceX)...)
b = append(b, tlv(0x87, readerX)...)
b = append(b, tlv(0x4c, nonce)...)
return append(b, tlv(0x93, constant)...)
}
func derive(shared, nonce, readerX, deviceX, versions []byte, contextName string, n int) ([]byte, error) {
// ANSI X9.63 SHA-256, 32 bytes: SHA256(Z || counter BE32 || nonce).
h := sha256.New()
h.Write(shared)
h.Write([]byte{0, 0, 0, 1})
h.Write(nonce)
key := h.Sum(nil)
info := append([]byte(nil), readerX...)
info = append(info, deviceX...)
info = append(info, nonce...)
info = append(info, 0x5e, 0, 1)
info = append(info, []byte(contextName)...)
info = append(info, tlv(0x5c, []byte{2, 0})...)
info = append(info, tlv(0x5c, versions)...)
out := make([]byte, n)
_, e := io.ReadFull(hkdf.New(sha256.New, key, nil, info), out)
return out, e
}
// Authenticate proves possession of an ACTIVE, already-enrolled device key.
// A successful SELECT, ECDH or 9000 status alone never grants access.
func Authenticate(ctx context.Context, t Transport, c provision.Credentials) (*Result, error) {
scalar, e := hex.DecodeString(c.ReaderPrivateKey)
if e != nil || len(scalar) != 32 {
return nil, fmt.Errorf("reader is not provisioned")
}
unique, e := hex.DecodeString(c.ReaderIdentifier)
if e != nil || len(unique) != 8 {
return nil, fmt.Errorf("invalid reader identifier")
}
d := new(big.Int).SetBytes(scalar)
curve := elliptic.P256()
if d.Sign() == 0 || d.Cmp(curve.Params().N) >= 0 {
return nil, fmt.Errorf("invalid reader scalar")
}
x, y := curve.ScalarBaseMult(scalar)
reader := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}
groupHash := sha256.Sum256(append([]byte("key-identifier"), scalar...))
id := append(append([]byte(nil), groupHash[:8]...), unique...)
selected, e := checked(ctx, t, "SELECT", []byte{0, 0xa4, 4, 0, 7, 0xa0, 0, 0, 8, 0x58, 1, 1, 0})
if e != nil {
return nil, e
}
sf, e := fields(selected)
if e != nil {
return nil, e
}
versions := sf[0x5c]
if len(versions) == 0 || len(versions)%2 != 0 {
return nil, fmt.Errorf("invalid supported versions")
}
supported := false
for i := 0; i < len(versions); i += 2 {
if bytes.Equal(versions[i:i+2], []byte{2, 0}) {
supported = true
}
}
if !supported {
return nil, fmt.Errorf("Home Key protocol 2.0 not supported")
}
log.Print("Home Key SELECT accepted; protocol=2.0")
eph, e := ecdh.P256().GenerateKey(rand.Reader)
if e != nil {
return nil, e
}
nonce := make([]byte, 16)
if _, e = rand.Read(nonce); e != nil {
return nil, e
}
pub := eph.PublicKey().Bytes()
data := tlv(0x5c, []byte{2, 0})
data = append(data, tlv(0x87, pub)...)
data = append(data, tlv(0x4c, nonce)...)
data = append(data, tlv(0x4d, id)...)
// STANDARD flag 0, UNLOCK transaction code 1.
auth0, e := checked(ctx, t, "AUTH0", command(0x80, 0, 1, data))
if e != nil {
return nil, e
}
af, e := fields(auth0)
if e != nil {
return nil, e
}
devicePub, e := ecdh.P256().NewPublicKey(af[0x86])
if e != nil {
return nil, fmt.Errorf("AUTH0: invalid device ephemeral key")
}
log.Print("Home Key AUTH0 key exchange complete")
shared, e := eph.ECDH(devicePub)
if e != nil {
return nil, e
}
deviceX := devicePub.Bytes()[1:33]
readerX := pub[1:33]
digest := sha256.Sum256(transcript(id, deviceX, readerX, nonce, []byte{0x41, 0x5d, 0x95, 0x69}))
r, s, e := ecdsa.Sign(rand.Reader, reader, digest[:])
if e != nil {
return nil, e
}
signature := make([]byte, 64)
r.FillBytes(signature[:32])
s.FillBytes(signature[32:])
encrypted, e := checked(ctx, t, "AUTH1", command(0x81, 0, 0, tlv(0x9e, signature)))
if e != nil {
return nil, e
}
keys, e := derive(shared, nonce, readerX, deviceX, versions, "Volatile", 48)
if e != nil {
return nil, e
}
plain, e := decryptResponse(encrypted, keys[:16], keys[32:], 0)
if e != nil {
return nil, fmt.Errorf("AUTH1: %w", e)
}
df, e := fields(plain)
if e != nil {
return nil, e
}
if len(df[0x4e]) != 6 || len(df[0x9e]) != 64 {
return nil, fmt.Errorf("AUTH1: invalid endpoint proof")
}
eid := hex.EncodeToString(df[0x4e])
proof := df[0x9e]
digest = sha256.Sum256(transcript(id, deviceX, readerX, nonce, []byte{0x4e, 0x88, 0x7b, 0x4c}))
for iid, issuer := range c.Issuers {
ep, ok := issuer.Endpoints[eid]
if !ok || ep.KeyState != 1 || ep.KeyType != 2 {
continue
}
raw, e := hex.DecodeString(ep.PublicKey)
if e != nil {
continue
}
x, y := elliptic.Unmarshal(curve, raw)
if x == nil {
continue
}
if !ecdsa.Verify(&ecdsa.PublicKey{Curve: curve, X: x, Y: y}, digest[:], new(big.Int).SetBytes(proof[:32]), new(big.Int).SetBytes(proof[32:])) {
continue
}
persistent, e := derive(shared, nonce, readerX, deviceX, versions, "Persistent", 32)
if e != nil {
return nil, e
}
return &Result{IssuerID: iid, EndpointID: eid, PublicKey: ep.PublicKey, PersistentKey: persistent}, nil
}
return nil, fmt.Errorf("AUTH1: unknown, inactive or invalid endpoint")
}
// Finish controls phone UX. The caller must recheck current authorization
// before sending success. Failure is also sent when authentication is rejected.
func Finish(ctx context.Context, t Transport, success bool) error {
p1 := byte(0)
if success {
p1 = 1
}
_, e := checked(ctx, t, "CONTROL FLOW", []byte{0x80, 0x3c, p1, 0})
return e
}